diff --git a/extensions/configuration-editing/package.json b/extensions/configuration-editing/package.json index 5defc86cf5d..be2d2e8ebae 100644 --- a/extensions/configuration-editing/package.json +++ b/extensions/configuration-editing/package.json @@ -88,6 +88,10 @@ "fileMatch": "/.vscode/tasks.json", "url": "vscode://schemas/tasks" }, + { + "fileMatch": "%APP_SETTINGS_HOME%/tasks.json", + "url": "vscode://schemas/tasks" + }, { "fileMatch": "%APP_SETTINGS_HOME%/snippets/*.json", "url": "vscode://schemas/snippets" diff --git a/extensions/typescript-language-features/src/features/task.ts b/extensions/typescript-language-features/src/features/task.ts index 59044029b69..48f9ca27d4a 100644 --- a/extensions/typescript-language-features/src/features/task.ts +++ b/extensions/typescript-language-features/src/features/task.ts @@ -20,7 +20,8 @@ type AutoDetect = 'on' | 'off' | 'build' | 'watch'; const exists = async (resource: vscode.Uri): Promise => { try { const stat = await vscode.workspace.fs.stat(resource); - return stat.type === vscode.FileType.File; + // stat.type is an enum flag + return !!(stat.type & vscode.FileType.File); } catch { return false; } diff --git a/src/vs/editor/common/modes.ts b/src/vs/editor/common/modes.ts index 1b0a5dd0fea..9db95948d24 100644 --- a/src/vs/editor/common/modes.ts +++ b/src/vs/editor/common/modes.ts @@ -436,7 +436,7 @@ export interface CompletionItem { preselect?: boolean; /** * A string or snippet that should be inserted in a document when selecting - * this completion. + * this completion. When `falsy` the [label](#CompletionItem.label) * is used. */ insertText: string; diff --git a/src/vs/editor/contrib/multicursor/test/multicursor.test.ts b/src/vs/editor/contrib/multicursor/test/multicursor.test.ts index 7724699eded..157a018ac0d 100644 --- a/src/vs/editor/contrib/multicursor/test/multicursor.test.ts +++ b/src/vs/editor/contrib/multicursor/test/multicursor.test.ts @@ -69,7 +69,8 @@ suite('Multicursor selection', () => { store: (key: string, value: any) => { queryState[key] = value; return Promise.resolve(); }, remove: (key) => undefined, logStorage: () => undefined, - migrate: (toWorkspace) => Promise.resolve(undefined) + migrate: (toWorkspace) => Promise.resolve(undefined), + flush: () => undefined } as IStorageService); test('issue #8817: Cursor position changes when you cancel multicursor', () => { diff --git a/src/vs/editor/contrib/suggest/test/suggestController.test.ts b/src/vs/editor/contrib/suggest/test/suggestController.test.ts new file mode 100644 index 00000000000..e3c00dbb01f --- /dev/null +++ b/src/vs/editor/contrib/suggest/test/suggestController.test.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { SuggestController } from 'vs/editor/contrib/suggest/suggestController'; +import { createTestCodeEditor, TestCodeEditor } from 'vs/editor/test/browser/testCodeEditor'; +import { TextModel } from 'vs/editor/common/model/textModel'; +import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; +import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; +import { IStorageService, InMemoryStorageService } from 'vs/platform/storage/common/storage'; +import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; +import { MockKeybindingService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; +import { ISuggestMemoryService } from 'vs/editor/contrib/suggest/suggestMemory'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { IEditorWorkerService } from 'vs/editor/common/services/editorWorkerService'; +import { mock } from 'vs/editor/contrib/suggest/test/suggestModel.test'; +import { Selection } from 'vs/editor/common/core/selection'; +import { CompletionProviderRegistry, CompletionItemKind, CompletionItemInsertTextRule } from 'vs/editor/common/modes'; +import { Event } from 'vs/base/common/event'; +import { SnippetController2 } from 'vs/editor/contrib/snippet/snippetController2'; + +suite('SuggestController', function () { + + const disposables = new DisposableStore(); + + let controller: SuggestController; + let editor: TestCodeEditor; + let model: TextModel; + + setup(function () { + disposables.clear(); + + const serviceCollection = new ServiceCollection( + [ITelemetryService, NullTelemetryService], + [IStorageService, new InMemoryStorageService()], + [IKeybindingService, new MockKeybindingService()], + [IEditorWorkerService, new class extends mock() { + computeWordRanges() { + return Promise.resolve({}); + } + }], + [ISuggestMemoryService, new class extends mock() { + memorize(): void { } + select(): number { return 0; } + }] + ); + + model = TextModel.createFromString('', undefined, undefined, URI.from({ scheme: 'test-ctrl', path: '/path.tst' })); + editor = createTestCodeEditor({ + model, + serviceCollection, + }); + + editor.registerAndInstantiateContribution(SnippetController2.ID, SnippetController2); + controller = editor.registerAndInstantiateContribution(SuggestController.ID, SuggestController); + }); + + test('postfix completion reports incorrect position #86984', async function () { + disposables.add(CompletionProviderRegistry.register({ scheme: 'test-ctrl' }, { + provideCompletionItems(doc, pos) { + return { + suggestions: [{ + kind: CompletionItemKind.Snippet, + label: 'let', + insertText: 'let ${1:name} = foo$0', + insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, + range: { startLineNumber: 1, startColumn: 9, endLineNumber: 1, endColumn: 11 }, + additionalTextEdits: [{ + text: '', + range: { startLineNumber: 1, startColumn: 5, endLineNumber: 1, endColumn: 9 } + }] + }] + }; + } + })); + + editor.setValue(' foo.le'); + editor.setSelection(new Selection(1, 11, 1, 11)); + + // trigger + let p1 = Event.toPromise(controller.model.onDidSuggest); + controller.triggerSuggest(); + await p1; + + // + let p2 = Event.toPromise(controller.model.onDidCancel); + controller.acceptSelectedSuggestion(false, false); + await p2; + + assert.equal(editor.getValue(), ' let name = foo'); + }); +}); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 1f40e30e414..58e9b0fa73d 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -4752,7 +4752,7 @@ declare namespace monaco.languages { preselect?: boolean; /** * A string or snippet that should be inserted in a document when selecting - * this completion. + * this completion. When `falsy` the [label](#CompletionItem.label) * is used. */ insertText: string; diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index 6edd9ff882a..870af29a60a 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -113,7 +113,7 @@ export class FileService extends Disposable implements IFileService { if (!provider) { const error = new Error(); error.name = 'ENOPRO'; - error.message = localize('noProviderFound', "No file system provider found for {0}", resource.toString()); + error.message = localize('noProviderFound', "No file system provider found for resource '{0}'", resource.toString()); throw error; } @@ -128,7 +128,7 @@ export class FileService extends Disposable implements IFileService { return provider; } - throw new Error('Provider neither has FileReadWrite, FileReadStream nor FileOpenReadWriteClose capability which is needed for the read operation.'); + throw new Error(`Provider for scheme '${resource.scheme}' neither has FileReadWrite, FileReadStream nor FileOpenReadWriteClose capability which is needed for the read operation.`); } private async withWriteProvider(resource: URI): Promise { @@ -138,7 +138,7 @@ export class FileService extends Disposable implements IFileService { return provider; } - throw new Error('Provider neither has FileReadWrite nor FileOpenReadWriteClose capability which is needed for the write operation.'); + throw new Error(`Provider for scheme '${resource.scheme}' neither has FileReadWrite nor FileOpenReadWriteClose capability which is needed for the write operation.`); } //#endregion @@ -160,10 +160,7 @@ export class FileService extends Disposable implements IFileService { // Specially handle file not found case as file operation result if (toFileSystemProviderErrorCode(error) === FileSystemProviderErrorCode.FileNotFound) { - throw new FileOperationError( - localize('fileNotFoundError', "File not found ({0})", this.resourceForError(resource)), - FileOperationResult.FILE_NOT_FOUND - ); + throw new FileOperationError(localize('fileNotFoundError', "File not found ({0})", this.resourceForError(resource)), FileOperationResult.FILE_NOT_FOUND); } // Bubble up any other error as is @@ -304,7 +301,7 @@ export class FileService extends Disposable implements IFileService { } async writeFile(resource: URI, bufferOrReadableOrStream: VSBuffer | VSBufferReadable | VSBufferReadableStream, options?: IWriteFileOptions): Promise { - const provider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(resource)); + const provider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(resource), resource); try { @@ -338,7 +335,7 @@ export class FileService extends Disposable implements IFileService { await this.doWriteBuffered(provider, resource, bufferOrReadableOrStream instanceof VSBuffer ? bufferToReadable(bufferOrReadableOrStream) : bufferOrReadableOrStream); } } catch (error) { - throw new FileOperationError(localize('err.write', "Unable to write file ({0})", ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options); + throw new FileOperationError(localize('err.write', "Unable to write file '{0}' ({1})", this.resourceForError(resource), ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options); } return this.resolve(resource, { resolveMetadata: true }); @@ -354,7 +351,7 @@ export class FileService extends Disposable implements IFileService { // file cannot be directory if ((stat.type & FileType.Directory) !== 0) { - throw new FileOperationError(localize('fileIsDirectoryError', "Expected file {0} is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options); + throw new FileOperationError(localize('fileIsDirectoryError', "Expected file '{0}' is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options); } // Dirty write prevention: if the file on disk has been changed and does not match our expected @@ -453,14 +450,14 @@ export class FileService extends Disposable implements IFileService { value: fileStream }; } catch (error) { - throw new FileOperationError(localize('err.read', "Unable to read file ({0})", ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options); + throw new FileOperationError(localize('err.read', "Unable to read file '{0}' ({1})", this.resourceForError(resource), ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options); } } private readFileStreamed(provider: IFileSystemProviderWithFileReadStreamCapability, resource: URI, token: CancellationToken, options: IReadFileOptions = Object.create(null)): VSBufferReadableStream { const fileStream = provider.readFileStream(resource, options, token); - return this.transformFileReadStream(fileStream, options); + return this.transformFileReadStream(resource, fileStream, options); } private readFileBuffered(provider: IFileSystemProviderWithOpenReadWriteCloseCapability, resource: URI, token: CancellationToken, options: IReadFileOptions = Object.create(null)): VSBufferReadableStream { @@ -469,13 +466,13 @@ export class FileService extends Disposable implements IFileService { bufferSize: this.BUFFER_SIZE }, token); - return this.transformFileReadStream(fileStream, options); + return this.transformFileReadStream(resource, fileStream, options); } - private transformFileReadStream(stream: ReadableStreamEvents, options: IReadFileOptions): VSBufferReadableStream { + private transformFileReadStream(resource: URI, stream: ReadableStreamEvents, options: IReadFileOptions): VSBufferReadableStream { return transform(stream, { data: data => data instanceof VSBuffer ? data : VSBuffer.wrap(data), - error: error => new FileOperationError(localize('err.read', "Unable to read file ({0})", ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options) + error: error => new FileOperationError(localize('err.read', "Unable to read file '{0}' ({1})", this.resourceForError(resource), ensureFileSystemProviderError(error).toString()), toFileOperationResult(error), options) }, data => VSBuffer.concat(data)); } @@ -493,7 +490,7 @@ export class FileService extends Disposable implements IFileService { } // Throw if file is too large to load - this.validateReadFileLimits(buffer.byteLength, options); + this.validateReadFileLimits(resource, buffer.byteLength, options); return bufferToStream(VSBuffer.wrap(buffer)); } @@ -503,7 +500,7 @@ export class FileService extends Disposable implements IFileService { // Throw if resource is a directory if (stat.isDirectory) { - throw new FileOperationError(localize('fileIsDirectoryError', "Expected file {0} is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options); + throw new FileOperationError(localize('fileIsDirectoryError', "Expected file '{0}' is actually a directory", this.resourceForError(resource)), FileOperationResult.FILE_IS_DIRECTORY, options); } // Throw if file not modified since (unless disabled) @@ -512,12 +509,12 @@ export class FileService extends Disposable implements IFileService { } // Throw if file is too large to load - this.validateReadFileLimits(stat.size, options); + this.validateReadFileLimits(resource, stat.size, options); return stat; } - private validateReadFileLimits(size: number, options?: IReadFileOptions): void { + private validateReadFileLimits(resource: URI, size: number, options?: IReadFileOptions): void { if (options?.limits) { let tooLargeErrorResult: FileOperationResult | undefined = undefined; @@ -530,7 +527,7 @@ export class FileService extends Disposable implements IFileService { } if (typeof tooLargeErrorResult === 'number') { - throw new FileOperationError(localize('fileTooLargeError', "File is too large to open"), tooLargeErrorResult); + throw new FileOperationError(localize('fileTooLargeError', "File '{0}' is too large to open", this.resourceForError(resource)), tooLargeErrorResult); } } } @@ -540,8 +537,8 @@ export class FileService extends Disposable implements IFileService { //#region Move/Copy/Delete/Create Folder async move(source: URI, target: URI, overwrite?: boolean): Promise { - const sourceProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(source)); - const targetProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(target)); + const sourceProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(source), source); + const targetProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(target), target); // move const mode = await this.doMoveCopy(sourceProvider, source, targetProvider, target, 'move', !!overwrite); @@ -555,7 +552,7 @@ export class FileService extends Disposable implements IFileService { async copy(source: URI, target: URI, overwrite?: boolean): Promise { const sourceProvider = await this.withReadProvider(source); - const targetProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(target)); + const targetProvider = this.throwIfFileSystemIsReadonly(await this.withWriteProvider(target), target); // copy const mode = await this.doMoveCopy(sourceProvider, source, targetProvider, target, 'copy', !!overwrite); @@ -678,11 +675,11 @@ export class FileService extends Disposable implements IFileService { } if (isSameResourceWithDifferentPathCase && mode === 'copy') { - throw new Error(localize('unableToMoveCopyError1', "Unable to copy when source is same as target with different path case on a case insensitive file system")); + throw new Error(localize('unableToMoveCopyError1', "Unable to copy when source '{0}' is same as target '{1}' with different path case on a case insensitive file system", this.resourceForError(source), this.resourceForError(target))); } if (!isSameResourceWithDifferentPathCase && isEqualOrParent(target, source, !isPathCaseSensitive)) { - throw new Error(localize('unableToMoveCopyError2', "Unable to move/copy when source is parent of target.")); + throw new Error(localize('unableToMoveCopyError2', "Unable to move/copy when source '{0}' is parent of target '{1}'.", this.resourceForError(source), this.resourceForError(target))); } } @@ -709,7 +706,7 @@ export class FileService extends Disposable implements IFileService { } async createFolder(resource: URI): Promise { - const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource)); + const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource), resource); // mkdir recursively await this.mkdirp(provider, resource); @@ -729,7 +726,7 @@ export class FileService extends Disposable implements IFileService { try { const stat = await provider.stat(directory); if ((stat.type & FileType.Directory) === 0) { - throw new Error(localize('mkdirExistsError', "{0} exists, but is not a directory", this.resourceForError(directory))); + throw new Error(localize('mkdirExistsError', "Path '{0}' already exists, but is not a directory", this.resourceForError(directory))); } break; // we have hit a directory that exists -> good @@ -756,21 +753,18 @@ export class FileService extends Disposable implements IFileService { } async del(resource: URI, options?: { useTrash?: boolean; recursive?: boolean; }): Promise { - const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource)); + const provider = this.throwIfFileSystemIsReadonly(await this.withProvider(resource), resource); // Validate trash support const useTrash = !!options?.useTrash; if (useTrash && !(provider.capabilities & FileSystemProviderCapabilities.Trash)) { - throw new Error(localize('err.trash', "Provider does not support trash.")); + throw new Error(localize('err.trash', "Provider for scheme '{0}' does not support trash.", resource.scheme)); } // Validate delete const exists = await this.exists(resource); if (!exists) { - throw new FileOperationError( - localize('fileNotFoundError', "File not found ({0})", this.resourceForError(resource)), - FileOperationResult.FILE_NOT_FOUND - ); + throw new FileOperationError(localize('fileNotFoundError', "File not found ({0})", this.resourceForError(resource)), FileOperationResult.FILE_NOT_FOUND); } // Validate recursive @@ -1059,9 +1053,9 @@ export class FileService extends Disposable implements IFileService { await this.doWriteUnbuffered(targetProvider, target, buffer); } - protected throwIfFileSystemIsReadonly(provider: T): T { + protected throwIfFileSystemIsReadonly(provider: T, resource: URI): T { if (provider.capabilities & FileSystemProviderCapabilities.Readonly) { - throw new FileOperationError(localize('err.readonly', "Resource can not be modified."), FileOperationResult.FILE_PERMISSION_DENIED); + throw new FileOperationError(localize('err.readonly', "Resource '{0}' can not be modified.", this.resourceForError(resource)), FileOperationResult.FILE_PERMISSION_DENIED); } return provider; diff --git a/src/vs/platform/storage/browser/storageService.ts b/src/vs/platform/storage/browser/storageService.ts index e1da1af90f8..c1fa72a27c6 100644 --- a/src/vs/platform/storage/browser/storageService.ts +++ b/src/vs/platform/storage/browser/storageService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle'; import { Event, Emitter } from 'vs/base/common/event'; import { IWorkspaceStorageChangeEvent, IStorageService, StorageScope, IWillSaveStateEvent, WillSaveStateReason, logStorage } from 'vs/platform/storage/common/storage'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; @@ -21,11 +21,11 @@ export class BrowserStorageService extends Disposable implements IStorageService _serviceBrand: undefined; - private readonly _onDidChangeStorage: Emitter = this._register(new Emitter()); - readonly onDidChangeStorage: Event = this._onDidChangeStorage.event; + private readonly _onDidChangeStorage = this._register(new Emitter()); + readonly onDidChangeStorage = this._onDidChangeStorage.event; - private readonly _onWillSaveState: Emitter = this._register(new Emitter()); - readonly onWillSaveState: Event = this._onWillSaveState.event; + private readonly _onWillSaveState = this._register(new Emitter()); + readonly onWillSaveState = this._onWillSaveState.event; private globalStorage: IStorage | undefined; private workspaceStorage: IStorage | undefined; @@ -37,45 +37,15 @@ export class BrowserStorageService extends Disposable implements IStorageService private workspaceStorageFile: URI | undefined; private initializePromise: Promise | undefined; - private periodicSaveScheduler = this._register(new RunOnceScheduler(() => this.collectState(), 5000)); - get hasPendingUpdate(): boolean { - return (!!this.globalStorageDatabase && this.globalStorageDatabase.hasPendingUpdate) || (!!this.workspaceStorageDatabase && this.workspaceStorageDatabase.hasPendingUpdate); - } + private readonly periodicFlushScheduler = this._register(new RunOnceScheduler(() => this.doFlushWhenIdle(), 5000 /* every 5s */)); + private runWhenIdleDisposable: IDisposable | undefined = undefined; constructor( @IEnvironmentService private readonly environmentService: IEnvironmentService, @IFileService private readonly fileService: IFileService ) { super(); - - // In the browser we do not have support for long running unload sequences. As such, - // we cannot ask for saving state in that moment, because that would result in a - // long running operation. - // Instead, periodically ask customers to save save. The library will be clever enough - // to only save state that has actually changed. - this.periodicSaveScheduler.schedule(); - } - - private collectState(): void { - runWhenIdle(() => { - - // this event will potentially cause new state to be stored - // since new state will only be created while the document - // has focus, one optimization is to not run this when the - // document has no focus, assuming that state has not changed - // - // another optimization is to not collect more state if we - // have a pending update already running which indicates - // that the connection is either slow or disconnected and - // thus unhealthy. - if (document.hasFocus() && !this.hasPendingUpdate) { - this._onWillSaveState.fire({ reason: WillSaveStateReason.NONE }); - } - - // repeat - this.periodicSaveScheduler.schedule(); - }); } initialize(payload: IWorkspaceInitializationPayload): Promise { @@ -109,6 +79,13 @@ export class BrowserStorageService extends Disposable implements IStorageService this.workspaceStorage.init(), this.globalStorage.init() ]); + + // In the browser we do not have support for long running unload sequences. As such, + // we cannot ask for saving state in that moment, because that would result in a + // long running operation. + // Instead, periodically ask customers to save save. The library will be clever enough + // to only save state that has actually changed. + this.periodicFlushScheduler.schedule(); } get(key: string, scope: StorageScope, fallbackValue: string): string; @@ -156,6 +133,40 @@ export class BrowserStorageService extends Disposable implements IStorageService throw new Error('Migrating storage is currently unsupported in Web'); } + private doFlushWhenIdle(): void { + + // Dispose any previous idle runner + this.runWhenIdleDisposable = dispose(this.runWhenIdleDisposable); + + // Run when idle + this.runWhenIdleDisposable = runWhenIdle(() => { + + // this event will potentially cause new state to be stored + // since new state will only be created while the document + // has focus, one optimization is to not run this when the + // document has no focus, assuming that state has not changed + // + // another optimization is to not collect more state if we + // have a pending update already running which indicates + // that the connection is either slow or disconnected and + // thus unhealthy. + if (document.hasFocus() && !this.hasPendingUpdate) { + this.flush(); + } + + // repeat + this.periodicFlushScheduler.schedule(); + }); + } + + get hasPendingUpdate(): boolean { + return (!!this.globalStorageDatabase && this.globalStorageDatabase.hasPendingUpdate) || (!!this.workspaceStorageDatabase && this.workspaceStorageDatabase.hasPendingUpdate); + } + + flush(): void { + this._onWillSaveState.fire({ reason: WillSaveStateReason.NONE }); + } + close(): void { // We explicitly do not close our DBs because writing data onBeforeUnload() // can result in unexpected results. Namely, it seems that - even though this @@ -167,6 +178,12 @@ export class BrowserStorageService extends Disposable implements IStorageService // get triggered in this phase. this.dispose(); } + + dispose(): void { + this.runWhenIdleDisposable = dispose(this.runWhenIdleDisposable); + + super.dispose(); + } } export class FileStorageDatabase extends Disposable implements IStorageDatabase { diff --git a/src/vs/platform/storage/common/storage.ts b/src/vs/platform/storage/common/storage.ts index 0094786e95b..8a97b4be30c 100644 --- a/src/vs/platform/storage/common/storage.ts +++ b/src/vs/platform/storage/common/storage.ts @@ -102,6 +102,13 @@ export interface IStorageService { * Migrate the storage contents to another workspace. */ migrate(toWorkspace: IWorkspaceInitializationPayload): Promise; + + /** + * Allows to flush state, e.g. in cases where a shutdown is + * imminent. This will send out the onWillSaveState to ask + * everyone for latest state. + */ + flush(): void; } export const enum StorageScope { @@ -126,10 +133,11 @@ export class InMemoryStorageService extends Disposable implements IStorageServic _serviceBrand: undefined; - private readonly _onDidChangeStorage: Emitter = this._register(new Emitter()); - readonly onDidChangeStorage: Event = this._onDidChangeStorage.event; + private readonly _onDidChangeStorage = this._register(new Emitter()); + readonly onDidChangeStorage = this._onDidChangeStorage.event; - readonly onWillSaveState = Event.None; + protected readonly _onWillSaveState = this._register(new Emitter()); + readonly onWillSaveState = this._onWillSaveState.event; private globalCache: Map = new Map(); private workspaceCache: Map = new Map(); @@ -215,6 +223,10 @@ export class InMemoryStorageService extends Disposable implements IStorageServic async migrate(toWorkspace: IWorkspaceInitializationPayload): Promise { // not supported } + + flush(): void { + this._onWillSaveState.fire({ reason: WillSaveStateReason.NONE }); + } } export async function logStorage(global: Map, workspace: Map, globalPath: string, workspacePath: string): Promise { diff --git a/src/vs/platform/storage/node/storageService.ts b/src/vs/platform/storage/node/storageService.ts index bdd7b454af1..db8c578e75b 100644 --- a/src/vs/platform/storage/node/storageService.ts +++ b/src/vs/platform/storage/node/storageService.ts @@ -16,6 +16,7 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment' import { IWorkspaceInitializationPayload, isWorkspaceIdentifier, isSingleFolderWorkspaceInitializationPayload } from 'vs/platform/workspaces/common/workspaces'; import { onUnexpectedError } from 'vs/base/common/errors'; import { assertIsDefined, assertAllDefined } from 'vs/base/common/types'; +import { RunOnceScheduler, runWhenIdle } from 'vs/base/common/async'; export class NativeStorageService extends Disposable implements IStorageService { @@ -38,6 +39,9 @@ export class NativeStorageService extends Disposable implements IStorageService private initializePromise: Promise | undefined; + private readonly periodicFlushScheduler = this._register(new RunOnceScheduler(() => this.doFlushWhenIdle(), 60000 /* every minute */)); + private runWhenIdleDisposable: IDisposable | undefined = undefined; + constructor( globalStorageDatabase: IStorageDatabase, @ILogService private readonly logService: ILogService, @@ -63,10 +67,17 @@ export class NativeStorageService extends Disposable implements IStorageService } private async doInitialize(payload: IWorkspaceInitializationPayload): Promise { + + // Init all storage locations await Promise.all([ this.initializeGlobalStorage(), this.initializeWorkspaceStorage(payload) ]); + + // On some OS we do not get enough time to persist state on shutdown (e.g. when + // Windows restarts after applying updates). In other cases, VSCode might crash, + // so we periodically save state to reduce the chance of loosing any state. + this.periodicFlushScheduler.schedule(); } private initializeGlobalStorage(): Promise { @@ -185,8 +196,32 @@ export class NativeStorageService extends Disposable implements IStorageService this.getStorage(scope).delete(key); } + private doFlushWhenIdle(): void { + + // Dispose any previous idle runner + this.runWhenIdleDisposable = dispose(this.runWhenIdleDisposable); + + // Run when idle + this.runWhenIdleDisposable = runWhenIdle(() => { + + // send event to collect state + this.flush(); + + // repeat + this.periodicFlushScheduler.schedule(); + }); + } + + flush(): void { + this._onWillSaveState.fire({ reason: WillSaveStateReason.NONE }); + } + async close(): Promise { + // Stop periodic scheduler and idle runner as we now collect state normally + this.periodicFlushScheduler.dispose(); + this.runWhenIdleDisposable = dispose(this.runWhenIdleDisposable); + // Signal as event so that clients can still store data this._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 70a44149556..f9ee79736bd 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -784,45 +784,7 @@ declare module 'vscode' { //#endregion - //#region André: debug API for inline debug adapters https://github.com/microsoft/vscode/issues/85544 - - /** - * A DebugProtocolMessage is an opaque stand-in type for the [ProtocolMessage](https://microsoft.github.io/debug-adapter-protocol/specification#Base_Protocol_ProtocolMessage) type defined in the Debug Adapter Protocol. - */ - export interface DebugProtocolMessage { - // Properties: see details [here](https://microsoft.github.io/debug-adapter-protocol/specification#Base_Protocol_ProtocolMessage). - } - - /** - * A debug adapter that implements the Debug Adapter Protocol can be registered with VS Code if it implements the DebugAdapter interface. - */ - export interface DebugAdapter extends Disposable { - - /** - * An event which fires after the debug adapter has sent a Debug Adapter Protocol message to VS Code. - * Messages can be requests, responses, or events. - */ - readonly onDidSendMessage: Event; - - /** - * Handle a Debug Adapter Protocol message. - * Messages can be requests, responses, or events. - * Results or errors are returned via onSendMessage events. - * @param message A Debug Adapter Protocol message - */ - handleMessage(message: DebugProtocolMessage): void; - } - - /** - * A debug adapter descriptor for an inline implementation. - */ - export class DebugAdapterInlineImplementation { - - /** - * Create a descriptor for an inline implementation of a debug adapter. - */ - constructor(implementation: DebugAdapter); - } + //#region Debug // deprecated diff --git a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts index 05447ed8a4b..0ae9f987450 100644 --- a/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts +++ b/src/vs/workbench/api/browser/mainThreadLanguageFeatures.ts @@ -505,13 +505,17 @@ export class MainThreadLanguageFeatures implements MainThreadLanguageFeaturesSha this._registrations.set(handle, callh.CallHierarchyProviderRegistry.register(selector, { prepareCallHierarchy: async (document, position, token) => { - const item = await this._proxy.$prepareCallHierarchy(handle, document.uri, position, token); - if (!item) { + const items = await this._proxy.$prepareCallHierarchy(handle, document.uri, position, token); + if (!items) { return undefined; } return { - dispose: () => this._proxy.$releaseCallHierarchy(handle, item._sessionId), - root: MainThreadLanguageFeatures._reviveCallHierarchyItemDto(item) + dispose: () => { + for (const item of items) { + this._proxy.$releaseCallHierarchy(handle, item._sessionId); + } + }, + roots: items.map(MainThreadLanguageFeatures._reviveCallHierarchyItemDto) }; }, diff --git a/src/vs/workbench/api/browser/mainThreadTask.ts b/src/vs/workbench/api/browser/mainThreadTask.ts index 5e9120d8360..79ae97c0082 100644 --- a/src/vs/workbench/api/browser/mainThreadTask.ts +++ b/src/vs/workbench/api/browser/mainThreadTask.ts @@ -514,15 +514,19 @@ export class MainThreadTask implements MainThreadTaskShape { if (TaskHandleDTO.is(value)) { const workspaceFolder = this._workspaceContextServer.getWorkspaceFolder(URI.revive(value.workspaceFolder)); if (workspaceFolder) { - this._taskService.getTask(workspaceFolder, value.id, true).then((task: Task) => { - this._taskService.run(task).then(undefined, reason => { - // eat the error, it has already been surfaced to the user and we don't care about it here - }); - const result: TaskExecutionDTO = { - id: value.id, - task: TaskDTO.from(task) - }; - resolve(result); + this._taskService.getTask(workspaceFolder, value.id, true).then((task: Task | undefined) => { + if (!task) { + reject(new Error('Task not found')); + } else { + this._taskService.run(task).then(undefined, reason => { + // eat the error, it has already been surfaced to the user and we don't care about it here + }); + const result: TaskExecutionDTO = { + id: value.id, + task: TaskDTO.from(task) + }; + resolve(result); + } }, (_error) => { reject(new Error('Task not found')); }); diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 1f8513f02bf..88b4782cca3 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -58,12 +58,12 @@ const configurationEntrySchema: IJSONSchema = { }, description: nls.localize('scope.enumDescriptions', 'Descriptions for enum values') }, - markdownEnumDescription: { + markdownEnumDescriptions: { type: 'array', items: { type: 'string', }, - description: nls.localize('scope.markdownEnumDescription', 'Descriptions for enum values in the markdown format.') + description: nls.localize('scope.markdownEnumDescriptions', 'Descriptions for enum values in the markdown format.') }, markdownDescription: { type: 'string', diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index c2d49e8fa42..2e879ed1eda 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1188,7 +1188,7 @@ export interface ExtHostLanguageFeaturesShape { $provideColorPresentations(handle: number, resource: UriComponents, colorInfo: IRawColorInfo, token: CancellationToken): Promise; $provideFoldingRanges(handle: number, resource: UriComponents, context: modes.FoldingContext, token: CancellationToken): Promise; $provideSelectionRanges(handle: number, resource: UriComponents, positions: IPosition[], token: CancellationToken): Promise; - $prepareCallHierarchy(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; + $prepareCallHierarchy(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise; $provideCallHierarchyIncomingCalls(handle: number, sessionId: string, itemId: string, token: CancellationToken): Promise; $provideCallHierarchyOutgoingCalls(handle: number, sessionId: string, itemId: string, token: CancellationToken): Promise; $releaseCallHierarchy(handle: number, sessionId: string): void; diff --git a/src/vs/workbench/api/common/extHostLanguageFeatures.ts b/src/vs/workbench/api/common/extHostLanguageFeatures.ts index a9482c1616c..ba0f638fefd 100644 --- a/src/vs/workbench/api/common/extHostLanguageFeatures.ts +++ b/src/vs/workbench/api/common/extHostLanguageFeatures.ts @@ -1200,18 +1200,23 @@ class CallHierarchyAdapter { private readonly _provider: vscode.CallHierarchyProvider ) { } - async prepareSession(uri: URI, position: IPosition, token: CancellationToken): Promise { + async prepareSession(uri: URI, position: IPosition, token: CancellationToken): Promise { const doc = this._documents.getDocument(uri); const pos = typeConvert.Position.to(position); - const item = await this._provider.prepareCallHierarchy(doc, pos, token); - if (!item) { + const items = await this._provider.prepareCallHierarchy(doc, pos, token); + if (!items) { return undefined; } - const sessionId = this._idPool.nextId(); + const sessionId = this._idPool.nextId(); this._cache.set(sessionId, new Map()); - return this._cacheAndConvertItem(sessionId, item); + + if (Array.isArray(items)) { + return items.map(item => this._cacheAndConvertItem(sessionId, item)); + } else { + return [this._cacheAndConvertItem(sessionId, items)]; + } } async provideCallsTo(sessionId: string, itemId: string, token: CancellationToken): Promise { @@ -1727,7 +1732,7 @@ export class ExtHostLanguageFeatures implements extHostProtocol.ExtHostLanguageF return this._createDisposable(handle); } - $prepareCallHierarchy(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise { + $prepareCallHierarchy(handle: number, resource: UriComponents, position: IPosition, token: CancellationToken): Promise { return this._withAdapter(handle, CallHierarchyAdapter, adapter => Promise.resolve(adapter.prepareSession(URI.revive(resource), position, token)), undefined); } diff --git a/src/vs/workbench/api/common/menusExtensionPoint.ts b/src/vs/workbench/api/common/menusExtensionPoint.ts index df971f54fad..4801b91d288 100644 --- a/src/vs/workbench/api/common/menusExtensionPoint.ts +++ b/src/vs/workbench/api/common/menusExtensionPoint.ts @@ -351,11 +351,8 @@ commandsExtensionPoint.setHandler(extensions => { let absoluteIcon: { dark: URI; light?: URI; } | ThemeIcon | undefined; if (icon) { if (typeof icon === 'string') { - if (extension.description.enableProposedApi) { - absoluteIcon = ThemeIcon.fromString(icon) || { dark: resources.joinPath(extension.description.extensionLocation, icon) }; - } else { - absoluteIcon = { dark: resources.joinPath(extension.description.extensionLocation, icon) }; - } + absoluteIcon = ThemeIcon.fromString(icon) || { dark: resources.joinPath(extension.description.extensionLocation, icon) }; + } else { absoluteIcon = { dark: resources.joinPath(extension.description.extensionLocation, icon.dark), diff --git a/src/vs/workbench/browser/dnd.ts b/src/vs/workbench/browser/dnd.ts index d4884d5c2a7..10d6cf85ef7 100644 --- a/src/vs/workbench/browser/dnd.ts +++ b/src/vs/workbench/browser/dnd.ts @@ -383,7 +383,7 @@ export function fillResourceDataTransfers(accessor: ServicesAccessor, resources: const model = textFileService.models.get(file.resource); if (model) { encoding = model.getEncoding(); - mode = model.textEditorModel?.getModeId(); + mode = model.getMode(); } } diff --git a/src/vs/workbench/browser/editor.ts b/src/vs/workbench/browser/editor.ts index e03a20900a5..af3e39d295e 100644 --- a/src/vs/workbench/browser/editor.ts +++ b/src/vs/workbench/browser/editor.ts @@ -9,6 +9,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; import { IConstructorSignature0, IInstantiationService, BrandedService } from 'vs/platform/instantiation/common/instantiation'; import { find } from 'vs/base/common/arrays'; +import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; export interface IEditorDescriptor { instantiate(instantiationService: IInstantiationService): BaseEditor; @@ -30,7 +31,7 @@ export interface IEditorRegistry { * @param inputDescriptors A set of constructor functions that return an instance of EditorInput for which the * registered editor should be used for. */ - registerEditor(descriptor: IEditorDescriptor, inputDescriptors: readonly SyncDescriptor[]): void; + registerEditor(descriptor: IEditorDescriptor, inputDescriptors: readonly SyncDescriptor[]): IDisposable; /** * Returns the editor descriptor for the given input or `undefined` if none. @@ -54,7 +55,7 @@ export interface IEditorRegistry { */ export class EditorDescriptor implements IEditorDescriptor { - public static create( + static create( ctor: { new(...services: Services): BaseEditor }, id: string, name: string @@ -87,14 +88,22 @@ export class EditorDescriptor implements IEditorDescriptor { class EditorRegistry implements IEditorRegistry { - private editors: EditorDescriptor[] = []; + private readonly editors: EditorDescriptor[] = []; private readonly mapEditorToInputs = new Map[]>(); - registerEditor(descriptor: EditorDescriptor, inputDescriptors: readonly SyncDescriptor[]): void { - // Register (Support multiple Editors per Input) + registerEditor(descriptor: EditorDescriptor, inputDescriptors: readonly SyncDescriptor[]): IDisposable { this.mapEditorToInputs.set(descriptor, inputDescriptors); this.editors.push(descriptor); + + return toDisposable(() => { + this.mapEditorToInputs.delete(descriptor); + + const index = this.editors.indexOf(descriptor); + if (index !== -1) { + this.editors.splice(index, 1); + } + }); } getEditor(input: EditorInput): EditorDescriptor | undefined { @@ -156,10 +165,6 @@ class EditorRegistry implements IEditorRegistry { return this.editors.slice(0); } - setEditors(editorsToSet: EditorDescriptor[]): void { - this.editors = editorsToSet; - } - getEditorInputs(): SyncDescriptor[] { const inputClasses: SyncDescriptor[] = []; for (const editor of this.editors) { diff --git a/src/vs/workbench/browser/parts/editor/textEditor.ts b/src/vs/workbench/browser/parts/editor/textEditor.ts index d3f45474cda..1996880cf6b 100644 --- a/src/vs/workbench/browser/parts/editor/textEditor.ts +++ b/src/vs/workbench/browser/parts/editor/textEditor.ts @@ -23,8 +23,6 @@ import { IEditorGroupsService, IEditorGroup } from 'vs/workbench/services/editor import { CancellationToken } from 'vs/base/common/cancellation'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; -const TEXT_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'textEditorViewState'; - export interface IEditorConfiguration { editor: object; diffEditor: object; @@ -35,6 +33,9 @@ export interface IEditorConfiguration { * be subclassed and not instantiated. */ export abstract class BaseTextEditor extends BaseEditor implements ITextEditor { + + static readonly TEXT_EDITOR_VIEW_STATE_PREFERENCE_KEY = 'textEditorViewState'; + private editorControl: IEditor | undefined; private editorContainer: HTMLElement | undefined; private hasPendingConfigurationChange: boolean | undefined; @@ -53,7 +54,7 @@ export abstract class BaseTextEditor extends BaseEditor implements ITextEditor { ) { super(id, telemetryService, themeService, storageService); - this.editorMemento = this.getEditorMemento(editorGroupService, TEXT_EDITOR_VIEW_STATE_PREFERENCE_KEY, 100); + this.editorMemento = this.getEditorMemento(editorGroupService, BaseTextEditor.TEXT_EDITOR_VIEW_STATE_PREFERENCE_KEY, 100); this._register(this.configurationService.onDidChangeConfiguration(e => { const resource = this.getResource(); diff --git a/src/vs/workbench/browser/workbench.ts b/src/vs/workbench/browser/workbench.ts index 5f1cbf50e79..f989b18197c 100644 --- a/src/vs/workbench/browser/workbench.ts +++ b/src/vs/workbench/browser/workbench.ts @@ -44,6 +44,7 @@ import { WorkbenchContextKeysHandler } from 'vs/workbench/browser/contextkeys'; import { coalesce } from 'vs/base/common/arrays'; import { InstantiationService } from 'vs/platform/instantiation/common/instantiationService'; import { Layout } from 'vs/workbench/browser/layout'; +import { IHostService } from 'vs/workbench/services/host/browser/host'; export class Workbench extends Layout { @@ -140,6 +141,7 @@ export class Workbench extends Layout { const lifecycleService = accessor.get(ILifecycleService); const storageService = accessor.get(IStorageService); const configurationService = accessor.get(IConfigurationService); + const hostService = accessor.get(IHostService); // Layout this.initLayout(accessor); @@ -151,7 +153,7 @@ export class Workbench extends Layout { this._register(instantiationService.createInstance(WorkbenchContextKeysHandler)); // Register Listeners - this.registerListeners(lifecycleService, storageService, configurationService); + this.registerListeners(lifecycleService, storageService, configurationService, hostService); // Render Workbench this.renderWorkbench(instantiationService, accessor.get(INotificationService) as NotificationService, storageService, configurationService); @@ -224,7 +226,8 @@ export class Workbench extends Layout { private registerListeners( lifecycleService: ILifecycleService, storageService: IStorageService, - configurationService: IConfigurationService + configurationService: IConfigurationService, + hostService: IHostService ): void { // Configuration changes @@ -248,6 +251,13 @@ export class Workbench extends Layout { this._onShutdown.fire(); this.dispose(); })); + + // In some environments we do not get enough time to persist state on shutdown. + // In other cases, VSCode might crash, so we periodically save state to reduce + // the chance of loosing any state. + // The window loosing focus is a good indication that the user has stopped working + // in that window so we pick that at a time to collect state. + this._register(hostService.onDidChangeFocus(focus => { if (!focus) { storageService.flush(); } })); } private fontAliasing: 'default' | 'antialiased' | 'none' | 'auto' | undefined; diff --git a/src/vs/workbench/common/editor.ts b/src/vs/workbench/common/editor.ts index b15a2202780..630cfd443a2 100644 --- a/src/vs/workbench/common/editor.ts +++ b/src/vs/workbench/common/editor.ts @@ -7,7 +7,7 @@ import { Event, Emitter } from 'vs/base/common/event'; import { assign } from 'vs/base/common/objects'; import { isUndefinedOrNull, withNullAsUndefined, assertIsDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; -import { IDisposable, Disposable } from 'vs/base/common/lifecycle'; +import { IDisposable, Disposable, toDisposable } from 'vs/base/common/lifecycle'; import { IEditor as ICodeEditor, IEditorViewState, ScrollType, IDiffEditor } from 'vs/editor/common/editorCommon'; import { IEditorModel, IEditorOptions, ITextEditorOptions, IBaseResourceInput, IResourceInput, EditorActivation, EditorOpenContext } from 'vs/platform/editor/common/editor'; import { IInstantiationService, IConstructorSignature0, ServicesAccessor, BrandedService } from 'vs/platform/instantiation/common/instantiation'; @@ -181,7 +181,7 @@ export interface IEditorInputFactoryRegistry { * @param editorInputId the identifier of the editor input * @param factory the editor input factory for serialization/deserialization */ - registerEditorInputFactory(editorInputId: string, ctor: { new(...Services: Services): IEditorInputFactory }): void; + registerEditorInputFactory(editorInputId: string, ctor: { new(...Services: Services): IEditorInputFactory }): IDisposable; /** * Returns the editor input factory for the given editor input. @@ -618,12 +618,12 @@ export const enum EncodingMode { export interface IEncodingSupport { /** - * Gets the encoding of the input if known. + * Gets the encoding of the type if known. */ getEncoding(): string | undefined; /** - * Sets the encoding for the input for saving. + * Sets the encoding for the type for saving. */ setEncoding(encoding: string, mode: EncodingMode): void; } @@ -631,7 +631,7 @@ export interface IEncodingSupport { export interface IModeSupport { /** - * Sets the language mode of the input. + * Sets the language mode of the type. */ setMode(mode: string): void; } @@ -1232,6 +1232,7 @@ export interface IEditorMemento { class EditorInputFactoryRegistry implements IEditorInputFactoryRegistry { private instantiationService: IInstantiationService | undefined; private fileInputFactory: IFileInputFactory | undefined; + private readonly editorInputFactoryConstructors: Map> = new Map(); private readonly editorInputFactoryInstances: Map = new Map(); @@ -1258,12 +1259,18 @@ class EditorInputFactoryRegistry implements IEditorInputFactoryRegistry { return assertIsDefined(this.fileInputFactory); } - registerEditorInputFactory(editorInputId: string, ctor: IConstructorSignature0): void { + registerEditorInputFactory(editorInputId: string, ctor: IConstructorSignature0): IDisposable { if (!this.instantiationService) { this.editorInputFactoryConstructors.set(editorInputId, ctor); } else { this.createEditorInputFactory(editorInputId, ctor, this.instantiationService); + } + + return toDisposable(() => { + this.editorInputFactoryConstructors.delete(editorInputId); + this.editorInputFactoryInstances.delete(editorInputId); + }); } getEditorInputFactory(editorInputId: string): IEditorInputFactory | undefined { diff --git a/src/vs/workbench/contrib/backup/common/backupModelTracker.ts b/src/vs/workbench/contrib/backup/common/backupModelTracker.ts index bbc393aff32..584291fb088 100644 --- a/src/vs/workbench/contrib/backup/common/backupModelTracker.ts +++ b/src/vs/workbench/contrib/backup/common/backupModelTracker.ts @@ -37,7 +37,7 @@ export class BackupModelTracker extends Disposable implements IWorkbenchContribu this._register(this.textFileService.models.onModelDisposed(e => this.discardBackup(e))); // Listen for untitled model changes - this._register(this.untitledTextEditorService.onDidCreate(e => this.onUntitledModelChanged(e))); + this._register(this.untitledTextEditorService.onDidCreate(e => this.onUntitledModelCreated(e))); this._register(this.untitledTextEditorService.onDidChangeContent(e => this.onUntitledModelChanged(e))); this._register(this.untitledTextEditorService.onDidDisposeModel(e => this.discardBackup(e))); @@ -65,6 +65,12 @@ export class BackupModelTracker extends Disposable implements IWorkbenchContribu } } + private onUntitledModelCreated(resource: Uri): void { + if (this.untitledTextEditorService.isDirty(resource)) { + this.untitledTextEditorService.loadOrCreate({ resource }).then(model => model.backup()); + } + } + private onUntitledModelChanged(resource: Uri): void { if (this.untitledTextEditorService.isDirty(resource)) { this.untitledTextEditorService.loadOrCreate({ resource }).then(model => model.backup()); diff --git a/src/vs/workbench/contrib/backup/common/backupRestorer.ts b/src/vs/workbench/contrib/backup/common/backupRestorer.ts index cfe126edd4b..a205249e0dc 100644 --- a/src/vs/workbench/contrib/backup/common/backupRestorer.ts +++ b/src/vs/workbench/contrib/backup/common/backupRestorer.ts @@ -31,7 +31,7 @@ export class BackupRestorer implements IWorkbenchContribution { this.lifecycleService.when(LifecyclePhase.Restored).then(() => this.doRestoreBackups()); } - private async doRestoreBackups(): Promise { + protected async doRestoreBackups(): Promise { // Find all files and untitled with backups const backups = await this.backupFileService.getWorkspaceFileBackups(); diff --git a/src/vs/workbench/contrib/backup/test/electron-browser/backupRestorer.test.ts b/src/vs/workbench/contrib/backup/test/electron-browser/backupRestorer.test.ts new file mode 100644 index 00000000000..1f55662a66d --- /dev/null +++ b/src/vs/workbench/contrib/backup/test/electron-browser/backupRestorer.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import * as platform from 'vs/base/common/platform'; +import * as os from 'os'; +import * as path from 'vs/base/common/path'; +import * as pfs from 'vs/base/node/pfs'; +import { URI } from 'vs/base/common/uri'; +import { createTextBufferFactory } from 'vs/editor/common/model/textModel'; +import { getRandomTestPath } from 'vs/base/test/node/testUtils'; +import { DefaultEndOfLine } from 'vs/editor/common/model'; +import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; +import { hashPath } from 'vs/workbench/services/backup/node/backupFileService'; +import { BackupModelTracker } from 'vs/workbench/contrib/backup/common/backupModelTracker'; +import { TestTextFileService, workbenchInstantiationService } from 'vs/workbench/test/workbenchTestServices'; +import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService'; +import { TextFileEditorModelManager } from 'vs/workbench/services/textfile/common/textFileEditorModelManager'; +import { BackupRestorer } from 'vs/workbench/contrib/backup/common/backupRestorer'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; +import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart'; +import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { EditorService } from 'vs/workbench/services/editor/browser/editorService'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { EditorInput } from 'vs/workbench/common/editor'; +import { FileEditorInput } from 'vs/workbench/contrib/files/common/editors/fileEditorInput'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { IEditorRegistry, EditorDescriptor, Extensions as EditorExtensions } from 'vs/workbench/browser/editor'; +import { TextFileEditor } from 'vs/workbench/contrib/files/browser/editors/textFileEditor'; +import { IBackupFileService } from 'vs/workbench/services/backup/common/backup'; +import { NodeTestBackupFileService } from 'vs/workbench/services/backup/test/electron-browser/backupFileService.test'; +import { dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { Schemas } from 'vs/base/common/network'; +import { isEqual } from 'vs/base/common/resources'; + +const userdataDir = getRandomTestPath(os.tmpdir(), 'vsctests', 'backuprestorer'); +const backupHome = path.join(userdataDir, 'Backups'); +const workspacesJsonPath = path.join(backupHome, 'workspaces.json'); + +const workspaceResource = URI.file(platform.isWindows ? 'c:\\workspace' : '/workspace'); +const workspaceBackupPath = path.join(backupHome, hashPath(workspaceResource)); +const fooFile = URI.file(platform.isWindows ? 'c:\\Foo' : '/Foo'); +const barFile = URI.file(platform.isWindows ? 'c:\\Bar' : '/Bar'); +const untitledFile1 = URI.from({ scheme: Schemas.untitled, path: 'Untitled-1' }); +const untitledFile2 = URI.from({ scheme: Schemas.untitled, path: 'Untitled-2' }); + +class TestBackupRestorer extends BackupRestorer { + async doRestoreBackups(): Promise { + return super.doRestoreBackups(); + } +} + +class ServiceAccessor { + constructor( + @ITextFileService public textFileService: TestTextFileService, + @IUntitledTextEditorService public untitledTextEditorService: IUntitledTextEditorService + ) { + } +} + +suite('BackupModelRestorer', () => { + let accessor: ServiceAccessor; + + let disposables: IDisposable[] = []; + + setup(async () => { + disposables.push(Registry.as(EditorExtensions.Editors).registerEditor( + EditorDescriptor.create( + TextFileEditor, + TextFileEditor.ID, + 'Text File Editor' + ), + [new SyncDescriptor(FileEditorInput)] + )); + + // Delete any existing backups completely and then re-create it. + await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); + await pfs.mkdirp(backupHome); + + return pfs.writeFile(workspacesJsonPath, ''); + }); + + teardown(async () => { + dispose(disposables); + disposables = []; + + (accessor.textFileService.models).clear(); + (accessor.textFileService.models).dispose(); + accessor.untitledTextEditorService.revertAll(); + + return pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); + }); + + test('Restore backups', async () => { + const backupFileService = new NodeTestBackupFileService(workspaceBackupPath); + const instantiationService = workbenchInstantiationService(); + instantiationService.stub(IBackupFileService, backupFileService); + + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); + + instantiationService.stub(IEditorGroupsService, part); + + const editorService: EditorService = instantiationService.createInstance(EditorService); + instantiationService.stub(IEditorService, editorService); + + accessor = instantiationService.createInstance(ServiceAccessor); + + const tracker = instantiationService.createInstance(BackupModelTracker); + const restorer = instantiationService.createInstance(TestBackupRestorer); + + // Backup 2 normal files and 2 untitled file + await backupFileService.backupResource(untitledFile1, createTextBufferFactory('untitled-1').create(DefaultEndOfLine.LF).createSnapshot(false)); + await backupFileService.backupResource(untitledFile2, createTextBufferFactory('untitled-2').create(DefaultEndOfLine.LF).createSnapshot(false)); + await backupFileService.backupResource(fooFile, createTextBufferFactory('fooFile').create(DefaultEndOfLine.LF).createSnapshot(false)); + await backupFileService.backupResource(barFile, createTextBufferFactory('barFile').create(DefaultEndOfLine.LF).createSnapshot(false)); + + // Verify backups restored and opened as dirty + await restorer.doRestoreBackups(); + assert.equal(editorService.editors.length, 4); + assert.ok(editorService.editors.every(editor => editor.isDirty())); + + let counter = 0; + for (const editor of editorService.editors) { + const resource = editor.getResource(); + if (isEqual(resource, untitledFile1)) { + const model = await accessor.untitledTextEditorService.createOrGet(resource).resolve(); + assert.equal(model.textEditorModel.getValue(), 'untitled-1'); + counter++; + } else if (isEqual(resource, untitledFile2)) { + const model = await accessor.untitledTextEditorService.createOrGet(resource).resolve(); + assert.equal(model.textEditorModel.getValue(), 'untitled-2'); + counter++; + } else if (isEqual(resource, fooFile)) { + const model = await accessor.textFileService.models.get(resource!)?.load(); + assert.equal(model?.textEditorModel?.getValue(), 'fooFile'); + counter++; + } else { + const model = await accessor.textFileService.models.get(resource!)?.load(); + assert.equal(model?.textEditorModel?.getValue(), 'barFile'); + counter++; + } + } + + assert.equal(counter, 4); + + part.dispose(); + tracker.dispose(); + }); +}); diff --git a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.ts b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.ts index ab12627fb50..c82d0a7739d 100644 --- a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.ts +++ b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchy.ts @@ -45,7 +45,7 @@ export interface OutgoingCall { } export interface CallHierarchySession { - root: CallHierarchyItem; + roots: CallHierarchyItem[]; dispose(): void; } @@ -92,15 +92,19 @@ export class CallHierarchyModel { if (!session) { return undefined; } - return new CallHierarchyModel(session.root._sessionId, provider, session.root, new RefCountedDisposabled(session)); + return new CallHierarchyModel(session.roots.reduce((p, c) => p + c._sessionId, ''), provider, session.roots, new RefCountedDisposabled(session)); } + readonly root: CallHierarchyItem; + private constructor( readonly id: string, readonly provider: CallHierarchyProvider, - readonly root: CallHierarchyItem, + readonly roots: CallHierarchyItem[], readonly ref: RefCountedDisposabled, - ) { } + ) { + this.root = roots[0]; + } dispose(): void { this.ref.release(); @@ -110,7 +114,7 @@ export class CallHierarchyModel { const that = this; return new class extends CallHierarchyModel { constructor() { - super(that.id, that.provider, item, that.ref.acquire()); + super(that.id, that.provider, [item], that.ref.acquire()); } }; } diff --git a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyTree.ts b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyTree.ts index c412f7b992a..2e9cec145d3 100644 --- a/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyTree.ts +++ b/src/vs/workbench/contrib/callHierarchy/browser/callHierarchyTree.ts @@ -43,7 +43,7 @@ export class DataSource implements IAsyncDataSource { async getChildren(element: CallHierarchyModel | Call): Promise { if (element instanceof CallHierarchyModel) { - return [new Call(element.root, undefined, element, undefined)]; + return element.roots.map(root => new Call(root, undefined, element, undefined)); } const { model, item } = element; diff --git a/src/vs/workbench/contrib/files/browser/editors/fileEditorTracker.ts b/src/vs/workbench/contrib/files/browser/editors/fileEditorTracker.ts index 92699a26fa3..a253fbaea92 100644 --- a/src/vs/workbench/contrib/files/browser/editors/fileEditorTracker.ts +++ b/src/vs/workbench/contrib/files/browser/editors/fileEditorTracker.ts @@ -5,7 +5,6 @@ import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; import { URI } from 'vs/base/common/uri'; -import * as resources from 'vs/base/common/resources'; import { IEditorViewState } from 'vs/editor/common/editorCommon'; import { toResource, SideBySideEditorInput, IWorkbenchEditorConfiguration, SideBySideEditor as SideBySideEditorChoice } from 'vs/workbench/common/editor'; import { ITextFileService, TextFileModelChangeEvent, ModelState } from 'vs/workbench/services/textfile/common/textfiles'; @@ -25,6 +24,8 @@ import { IEditorGroupsService, IEditorGroup } from 'vs/workbench/services/editor import { timeout } from 'vs/base/common/async'; import { withNullAsUndefined } from 'vs/base/common/types'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { isEqualOrParent, joinPath } from 'vs/base/common/resources'; +import { IExplorerService } from 'vs/workbench/contrib/files/common/files'; export class FileEditorTracker extends Disposable implements IWorkbenchContribution { @@ -42,7 +43,8 @@ export class FileEditorTracker extends Disposable implements IWorkbenchContribut @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, @IHostService private readonly hostService: IHostService, - @ICodeEditorService private readonly codeEditorService: ICodeEditorService + @ICodeEditorService private readonly codeEditorService: ICodeEditorService, + @IExplorerService private readonly explorerService: IExplorerService ) { super(); @@ -101,22 +103,19 @@ export class FileEditorTracker extends Disposable implements IWorkbenchContribut // Update Editor if file (or any parent of the input) got renamed or moved const resource = editor.getResource(); - if (resources.isEqualOrParent(resource, oldResource)) { + if (isEqualOrParent(resource, oldResource)) { let reopenFileResource: URI; if (oldResource.toString() === resource.toString()) { reopenFileResource = newResource; // file got moved } else { - const index = this.getIndexOfPath(resource.path, oldResource.path, resources.hasToIgnoreCase(resource)); - reopenFileResource = resources.joinPath(newResource, resource.path.substr(index + oldResource.path.length + 1)); // parent folder got moved + const index = this.getIndexOfPath(resource.path, oldResource.path, this.explorerService.shouldIgnoreCase(resource)); + reopenFileResource = joinPath(newResource, resource.path.substr(index + oldResource.path.length + 1)); // parent folder got moved } let encoding: string | undefined = undefined; - let mode: string | undefined = undefined; - const model = this.textFileService.models.get(resource); if (model) { encoding = model.getEncoding(); - mode = model.textEditorModel?.getModeId(); } this.editorService.replaceEditors([{ @@ -124,7 +123,6 @@ export class FileEditorTracker extends Disposable implements IWorkbenchContribut replacement: { resource: reopenFileResource, encoding, - mode, options: { preserveFocus: true, pinned: group.isPinned(editor), @@ -199,7 +197,7 @@ export class FileEditorTracker extends Disposable implements IWorkbenchContribut // Do NOT close any opened editor that matches the resource path (either equal or being parent) of the // resource we move to (movedTo). Otherwise we would close a resource that has been renamed to the same // path but different casing. - if (movedTo && resources.isEqualOrParent(resource, movedTo)) { + if (movedTo && isEqualOrParent(resource, movedTo)) { return; } @@ -207,7 +205,7 @@ export class FileEditorTracker extends Disposable implements IWorkbenchContribut if (arg1 instanceof FileChangesEvent) { matches = arg1.contains(resource, FileChangeType.DELETED); } else { - matches = resources.isEqualOrParent(resource, arg1); + matches = isEqualOrParent(resource, arg1); } if (!matches) { diff --git a/src/vs/workbench/contrib/files/browser/fileActions.ts b/src/vs/workbench/contrib/files/browser/fileActions.ts index d682f3b8d69..5c0bb70b565 100644 --- a/src/vs/workbench/contrib/files/browser/fileActions.ts +++ b/src/vs/workbench/contrib/files/browser/fileActions.ts @@ -7,7 +7,7 @@ import 'vs/css!./media/fileactions'; import * as nls from 'vs/nls'; import { isWindows, isWeb } from 'vs/base/common/platform'; import * as extpath from 'vs/base/common/extpath'; -import { extname, basename } from 'vs/base/common/path'; +import { extname, basename, posix, win32 } from 'vs/base/common/path'; import * as resources from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; import { toErrorMessage } from 'vs/base/common/errorMessage'; @@ -45,6 +45,7 @@ import { asDomUri, triggerDownload } from 'vs/base/browser/dom'; import { mnemonicButtonLabel } from 'vs/base/common/labels'; import { IFilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; import { IWorkingCopyService, IWorkingCopy } from 'vs/workbench/services/workingCopy/common/workingCopyService'; +import { ILabelService } from 'vs/platform/label/common/label'; export const NEW_FILE_COMMAND_ID = 'explorer.newFile'; export const NEW_FILE_LABEL = nls.localize('newFile', "New File"); @@ -328,12 +329,12 @@ function containsBothDirectoryAndFile(distinctElements: ExplorerItem[]): boolean } -export function findValidPasteFileTarget(targetFolder: ExplorerItem, fileToPaste: { resource: URI, isDirectory?: boolean, allowOverwrite: boolean }, incrementalNaming: 'simple' | 'smart'): URI { +export function findValidPasteFileTarget(explorerService: IExplorerService, targetFolder: ExplorerItem, fileToPaste: { resource: URI, isDirectory?: boolean, allowOverwrite: boolean }, incrementalNaming: 'simple' | 'smart'): URI { let name = resources.basenameOrAuthority(fileToPaste.resource); let candidate = resources.joinPath(targetFolder.resource, name); while (true && !fileToPaste.allowOverwrite) { - if (!targetFolder.root.find(candidate)) { + if (!explorerService.findClosest(candidate)) { break; } @@ -854,6 +855,7 @@ async function openExplorerAndCreate(accessor: ServicesAccessor, isFolder: boole const editorService = accessor.get(IEditorService); const viewletService = accessor.get(IViewletService); const notificationService = accessor.get(INotificationService); + const labelService = accessor.get(ILabelService); await viewletService.openViewlet(VIEWLET_ID, true); @@ -870,13 +872,16 @@ async function openExplorerAndCreate(accessor: ServicesAccessor, isFolder: boole throw new Error('Parent folder is readonly.'); } - const newStat = new NewExplorerItem(folder, isFolder); + const newStat = new NewExplorerItem(explorerService, folder, isFolder); await folder.fetchChildren(fileService, explorerService); folder.addChild(newStat); const onSuccess = (value: string): Promise => { - const createPromise = isFolder ? fileService.createFolder(resources.joinPath(folder.resource, value)) : textFileService.create(resources.joinPath(folder.resource, value)); + const separator = labelService.getSeparator(folder.resource.scheme); + const resource = folder.resource.with({ path: separator === '/' ? posix.join(folder.resource.path, value) : win32.join(folder.resource.path, value) }); + const createPromise = isFolder ? fileService.createFolder(resource) : textFileService.create(resource); + return createPromise.then(created => { refreshIfSeparator(value, explorerService); return isFolder ? explorerService.select(created.resource, true) @@ -1049,7 +1054,7 @@ export const pasteFileHandler = async (accessor: ServicesAccessor) => { } const incrementalNaming = configurationService.getValue().explorer.incrementalNaming; - const targetFile = findValidPasteFileTarget(target, { resource: fileToPaste, isDirectory: fileToPasteStat.isDirectory, allowOverwrite: pasteShouldMove }, incrementalNaming); + const targetFile = findValidPasteFileTarget(explorerService, target, { resource: fileToPaste, isDirectory: fileToPasteStat.isDirectory, allowOverwrite: pasteShouldMove }, incrementalNaming); // Move/Copy File if (pasteShouldMove) { diff --git a/src/vs/workbench/contrib/files/browser/views/explorerView.ts b/src/vs/workbench/contrib/files/browser/views/explorerView.ts index bd1e27a2150..2f6c9edc6f4 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerView.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerView.ts @@ -83,7 +83,6 @@ export class ExplorerView extends ViewPane { private compressedFocusContext: IContextKey; private compressedFocusFirstContext: IContextKey; private compressedFocusLastContext: IContextKey; - private compressedNavigationController: ICompressedNavigationController | undefined; // Refresh is needed on the initial explorer open private shouldRefresh = true; @@ -286,18 +285,17 @@ export class ExplorerView extends ViewPane { getContext(respectMultiSelection: boolean): ExplorerItem[] { let focusedStat: ExplorerItem | undefined; - if (this.compressedNavigationController) { - focusedStat = this.compressedNavigationController.current; - } else { - const focus = this.tree.getFocus(); - focusedStat = focus.length ? focus[0] : undefined; - } + const focus = this.tree.getFocus(); + focusedStat = focus.length ? focus[0] : undefined; + + const compressedNavigationController = focusedStat && this.renderer.getCompressedNavigationController(focusedStat); + focusedStat = compressedNavigationController ? compressedNavigationController.current : focusedStat; const selectedStats: ExplorerItem[] = []; for (const stat of this.tree.getSelection()) { const controller = this.renderer.getCompressedNavigationController(stat); - if (controller && focusedStat && controller === this.compressedNavigationController) { + if (controller && focusedStat && controller === compressedNavigationController) { if (stat === focusedStat) { selectedStats.push(stat); } @@ -414,18 +412,18 @@ export class ExplorerView extends ViewPane { this._register(explorerNavigator); // Open when selecting via keyboard this._register(explorerNavigator.onDidOpenResource(async e => { - const selection = this.tree.getSelection(); + const element = e.element; // Do not react if the user is expanding selection via keyboard. // Check if the item was previously also selected, if yes the user is simply expanding / collapsing current selection #66589. const shiftDown = e.browserEvent instanceof KeyboardEvent && e.browserEvent.shiftKey; - if (selection.length === 1 && !shiftDown) { - if (selection[0].isDirectory || this.explorerService.isEditable(undefined)) { + if (element && !shiftDown) { + if (element.isDirectory || this.explorerService.isEditable(undefined)) { // Do not react if user is clicking on explorer items while some are being edited #70276 // Do not react if clicking on directories return; } this.telemetryService.publicLog2('workbenchActionExecuted', { id: 'workbench.files.openFile', from: 'explorer' }); - await this.editorService.openEditor({ resource: selection[0].resource, options: { preserveFocus: e.editorOptions.preserveFocus, pinned: e.editorOptions.pinned } }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + await this.editorService.openEditor({ resource: element.resource, options: { preserveFocus: e.editorOptions.preserveFocus, pinned: e.editorOptions.pinned } }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); } })); @@ -532,16 +530,15 @@ export class ExplorerView extends ViewPane { this.resourceMoveableToTrash.reset(); } - this.compressedNavigationController = stat && this.renderer.getCompressedNavigationController(stat); + const compressedNavigationController = stat && this.renderer.getCompressedNavigationController(stat); - if (!this.compressedNavigationController) { + if (!compressedNavigationController) { this.compressedFocusContext.set(false); return; } this.compressedFocusContext.set(true); - // this.compressedNavigationController.last(); - this.updateCompressedNavigationContextKeys(this.compressedNavigationController); + this.updateCompressedNavigationContextKeys(compressedNavigationController); } // General methods @@ -691,39 +688,47 @@ export class ExplorerView extends ViewPane { } previousCompressedStat(): void { - if (!this.compressedNavigationController) { + const focused = this.tree.getFocus(); + if (!focused.length) { return; } - this.compressedNavigationController.previous(); - this.updateCompressedNavigationContextKeys(this.compressedNavigationController); + const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationController.previous(); + this.updateCompressedNavigationContextKeys(compressedNavigationController); } nextCompressedStat(): void { - if (!this.compressedNavigationController) { + const focused = this.tree.getFocus(); + if (!focused.length) { return; } - this.compressedNavigationController.next(); - this.updateCompressedNavigationContextKeys(this.compressedNavigationController); + const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationController.next(); + this.updateCompressedNavigationContextKeys(compressedNavigationController); } firstCompressedStat(): void { - if (!this.compressedNavigationController) { + const focused = this.tree.getFocus(); + if (!focused.length) { return; } - this.compressedNavigationController.first(); - this.updateCompressedNavigationContextKeys(this.compressedNavigationController); + const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationController.first(); + this.updateCompressedNavigationContextKeys(compressedNavigationController); } lastCompressedStat(): void { - if (!this.compressedNavigationController) { + const focused = this.tree.getFocus(); + if (!focused.length) { return; } - this.compressedNavigationController.last(); - this.updateCompressedNavigationContextKeys(this.compressedNavigationController); + const compressedNavigationController = this.renderer.getCompressedNavigationController(focused[0])!; + compressedNavigationController.last(); + this.updateCompressedNavigationContextKeys(compressedNavigationController); } private updateCompressedNavigationContextKeys(controller: ICompressedNavigationController): void { diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index 96ad2636400..c28b54aaaba 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -20,7 +20,7 @@ import { IContextViewService } from 'vs/platform/contextview/browser/contextView import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configuration/common/configuration'; import { IFilesConfiguration, IExplorerService } from 'vs/workbench/contrib/files/common/files'; -import { dirname, joinPath, isEqualOrParent, basename, hasToIgnoreCase, distinctParents } from 'vs/base/common/resources'; +import { dirname, joinPath, isEqualOrParent, basename, distinctParents } from 'vs/base/common/resources'; import { InputBox, MessageType } from 'vs/base/browser/ui/inputbox/inputBox'; import { localize } from 'vs/nls'; import { attachInputBoxStyler } from 'vs/platform/theme/common/styler'; @@ -95,7 +95,7 @@ export class ExplorerDataSource implements IAsyncDataSource { // Check for name collisions const targetNames = new Set(); if (targetStat.children) { - const ignoreCase = hasToIgnoreCase(target.resource); + const ignoreCase = this.explorerService.shouldIgnoreCase(target.resource); targetStat.children.forEach(child => { targetNames.add(ignoreCase ? child.name.toLowerCase() : child.name); }); @@ -929,7 +929,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { // Run add in sequence const addPromisesFactory: ITask>[] = []; await Promise.all(resources.map(async resource => { - if (targetNames.has(!hasToIgnoreCase(resource) ? basename(resource) : basename(resource).toLowerCase())) { + if (targetNames.has(this.explorerService.shouldIgnoreCase(resource) ? basename(resource).toLowerCase() : basename(resource))) { const confirmationResult = await this.dialogService.confirm(getFileOverwriteConfirm(basename(resource))); if (!confirmationResult.confirmed) { return; @@ -1031,7 +1031,7 @@ export class FileDragAndDrop implements ITreeDragAndDrop { // Reuse duplicate action if user copies if (isCopy) { const incrementalNaming = this.configurationService.getValue().explorer.incrementalNaming; - const stat = await this.textFileService.copy(source.resource, findValidPasteFileTarget(target, { resource: source.resource, isDirectory: source.isDirectory, allowOverwrite: false }, incrementalNaming)); + const stat = await this.textFileService.copy(source.resource, findValidPasteFileTarget(this.explorerService, target, { resource: source.resource, isDirectory: source.isDirectory, allowOverwrite: false }, incrementalNaming)); if (!stat.isDirectory) { await this.editorService.openEditor({ resource: stat.resource, options: { pinned: true } }); } diff --git a/src/vs/workbench/contrib/files/common/explorerModel.ts b/src/vs/workbench/contrib/files/common/explorerModel.ts index dd17f19dfb6..e7b364edb86 100644 --- a/src/vs/workbench/contrib/files/common/explorerModel.ts +++ b/src/vs/workbench/contrib/files/common/explorerModel.ts @@ -6,7 +6,6 @@ import { URI } from 'vs/base/common/uri'; import { isEqual } from 'vs/base/common/extpath'; import { posix } from 'vs/base/common/path'; -import * as resources from 'vs/base/common/resources'; import { ResourceMap } from 'vs/base/common/map'; import { IFileStat, IFileService, FileSystemProviderCapabilities } from 'vs/platform/files/common/files'; import { rtrim, startsWithIgnoreCase, startsWith, equalsIgnoreCase } from 'vs/base/common/strings'; @@ -16,6 +15,7 @@ import { IDisposable, dispose } from 'vs/base/common/lifecycle'; import { memoize } from 'vs/base/common/decorators'; import { Emitter, Event } from 'vs/base/common/event'; import { IExplorerService } from 'vs/workbench/contrib/files/common/files'; +import { joinPath, isEqualOrParent, basenameOrAuthority } from 'vs/base/common/resources'; export class ExplorerModel implements IDisposable { @@ -23,9 +23,12 @@ export class ExplorerModel implements IDisposable { private _listener: IDisposable; private readonly _onDidChangeRoots = new Emitter(); - constructor(private readonly contextService: IWorkspaceContextService) { + constructor( + private readonly contextService: IWorkspaceContextService, + explorerService: IExplorerService + ) { const setRoots = () => this._roots = this.contextService.getWorkspace().folders - .map(folder => new ExplorerItem(folder.uri, undefined, true, false, false, folder.name)); + .map(folder => new ExplorerItem(folder.uri, explorerService, undefined, true, false, false, folder.name)); setRoots(); this._listener = this.contextService.onDidChangeWorkspaceFolders(() => { @@ -80,11 +83,12 @@ export class ExplorerItem { constructor( public resource: URI, + private readonly explorerService: IExplorerService, private _parent: ExplorerItem | undefined, private _isDirectory?: boolean, private _isSymbolicLink?: boolean, private _isReadonly?: boolean, - private _name: string = resources.basenameOrAuthority(resource), + private _name: string = basenameOrAuthority(resource), private _mtime?: number, ) { this._isDirectoryResolved = false; @@ -154,8 +158,8 @@ export class ExplorerItem { return this === this.root; } - static create(service: IFileService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem { - const stat = new ExplorerItem(raw.resource, parent, raw.isDirectory, raw.isSymbolicLink, service.hasCapability(raw.resource, FileSystemProviderCapabilities.Readonly), raw.name, raw.mtime); + static create(explorerService: IExplorerService, fileService: IFileService, raw: IFileStat, parent: ExplorerItem | undefined, resolveTo?: readonly URI[]): ExplorerItem { + const stat = new ExplorerItem(raw.resource, explorerService, parent, raw.isDirectory, raw.isSymbolicLink, fileService.hasCapability(raw.resource, FileSystemProviderCapabilities.Readonly), raw.name, raw.mtime); // Recursively add children if present if (stat.isDirectory) { @@ -164,13 +168,13 @@ export class ExplorerItem { // the folder is fully resolved if either it has a list of children or the client requested this by using the resolveTo // array of resource path to resolve. stat._isDirectoryResolved = !!raw.children || (!!resolveTo && resolveTo.some((r) => { - return resources.isEqualOrParent(r, stat.resource); + return isEqualOrParent(r, stat.resource); })); // Recurse into children if (raw.children) { for (let i = 0, len = raw.children.length; i < len; i++) { - const child = ExplorerItem.create(service, raw.children[i], stat, resolveTo); + const child = ExplorerItem.create(explorerService, fileService, raw.children[i], stat, resolveTo); stat.addChild(child); } } @@ -262,7 +266,7 @@ export class ExplorerItem { const resolveMetadata = explorerService.sortOrder === 'modified'; try { const stat = await fileService.resolve(this.resource, { resolveSingleChildDescendants: true, resolveMetadata }); - const resolved = ExplorerItem.create(fileService, stat, this); + const resolved = ExplorerItem.create(explorerService, fileService, stat, this); ExplorerItem.mergeLocalWithDisk(resolved, this); } catch (e) { this.isError = true; @@ -302,7 +306,7 @@ export class ExplorerItem { } private getPlatformAwareName(name: string): string { - return (!name || !resources.hasToIgnoreCase(this.resource)) ? name : name.toLowerCase(); + return this.explorerService.shouldIgnoreCase(this.resource) ? name.toLowerCase() : name; } /** @@ -319,7 +323,7 @@ export class ExplorerItem { private updateResource(recursive: boolean): void { if (this._parent) { - this.resource = resources.joinPath(this._parent.resource, this.name); + this.resource = joinPath(this._parent.resource, this.name); } if (recursive) { @@ -352,16 +356,17 @@ export class ExplorerItem { find(resource: URI): ExplorerItem | null { // Return if path found // For performance reasons try to do the comparison as fast as possible + const ignoreCase = this.explorerService.shouldIgnoreCase(resource); if (resource && this.resource.scheme === resource.scheme && equalsIgnoreCase(this.resource.authority, resource.authority) && - (resources.hasToIgnoreCase(resource) ? startsWithIgnoreCase(resource.path, this.resource.path) : startsWith(resource.path, this.resource.path))) { - return this.findByPath(rtrim(resource.path, posix.sep), this.resource.path.length); + (ignoreCase ? startsWithIgnoreCase(resource.path, this.resource.path) : startsWith(resource.path, this.resource.path))) { + return this.findByPath(rtrim(resource.path, posix.sep), this.resource.path.length, ignoreCase); } return null; //Unable to find } - private findByPath(path: string, index: number): ExplorerItem | null { - if (isEqual(rtrim(this.resource.path, posix.sep), path, resources.hasToIgnoreCase(this.resource))) { + private findByPath(path: string, index: number, ignoreCase: boolean): ExplorerItem | null { + if (isEqual(rtrim(this.resource.path, posix.sep), path, ignoreCase)) { return this; } @@ -383,7 +388,7 @@ export class ExplorerItem { if (child) { // We found a child with the given name, search inside it - return child.findByPath(path, indexOfNextSep); + return child.findByPath(path, indexOfNextSep, ignoreCase); } } @@ -392,7 +397,7 @@ export class ExplorerItem { } export class NewExplorerItem extends ExplorerItem { - constructor(parent: ExplorerItem, isDirectory: boolean) { - super(URI.file(''), parent, isDirectory); + constructor(explorerService: IExplorerService, parent: ExplorerItem, isDirectory: boolean) { + super(URI.file(''), explorerService, parent, isDirectory); } } diff --git a/src/vs/workbench/contrib/files/common/explorerService.ts b/src/vs/workbench/contrib/files/common/explorerService.ts index 23311249367..d2eed899780 100644 --- a/src/vs/workbench/contrib/files/common/explorerService.ts +++ b/src/vs/workbench/contrib/files/common/explorerService.ts @@ -9,8 +9,8 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { IExplorerService, IFilesConfiguration, SortOrder, SortOrderConfiguration, IContextProvider } from 'vs/workbench/contrib/files/common/files'; import { ExplorerItem, ExplorerModel } from 'vs/workbench/contrib/files/common/explorerModel'; import { URI } from 'vs/base/common/uri'; -import { FileOperationEvent, FileOperation, IFileStat, IFileService, FileChangesEvent, FILES_EXCLUDE_CONFIG, FileChangeType, IResolveFileOptions } from 'vs/platform/files/common/files'; -import { dirname } from 'vs/base/common/resources'; +import { FileOperationEvent, FileOperation, IFileStat, IFileService, FileChangesEvent, FILES_EXCLUDE_CONFIG, FileChangeType, IResolveFileOptions, FileSystemProviderCapabilities } from 'vs/platform/files/common/files'; +import { dirname, hasToIgnoreCase } from 'vs/base/common/resources'; import { memoize } from 'vs/base/common/decorators'; import { ResourceGlobMatcher } from 'vs/workbench/common/resources'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; @@ -41,8 +41,9 @@ export class ExplorerService implements IExplorerService { private editable: { stat: ExplorerItem, data: IEditableData } | undefined; private _sortOrder: SortOrder; private cutItems: ExplorerItem[] | undefined; - private fileSystemProviderSchemes = new Set(); private contextProvider: IContextProvider | undefined; + private fileSystemProviderCaseSensitivity = new Map(); + private model: ExplorerModel; constructor( @IFileService private fileService: IFileService, @@ -53,6 +54,29 @@ export class ExplorerService implements IExplorerService { @IEditorService private editorService: IEditorService, ) { this._sortOrder = this.configurationService.getValue('explorer.sortOrder'); + + this.model = new ExplorerModel(this.contextService, this); + this.disposables.add(this.model); + this.disposables.add(this.fileService.onAfterOperation(e => this.onFileOperation(e))); + this.disposables.add(this.fileService.onFileChanges(e => this.onFileChanges(e))); + this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue()))); + this.disposables.add(this.fileService.onDidChangeFileSystemProviderRegistrations(e => { + const provider = e.provider; + if (e.added && provider) { + const alreadyRegistered = this.fileSystemProviderCaseSensitivity.has(e.scheme); + const readCapability = () => this.fileSystemProviderCaseSensitivity.set(e.scheme, !!(provider.capabilities & FileSystemProviderCapabilities.PathCaseSensitive)); + readCapability(); + + if (alreadyRegistered) { + // A file system provider got re-registered, we should update all file stats since they might change (got read-only) + this.model.roots.forEach(r => r.forgetChildren()); + this._onDidChangeItem.fire({ recursive: true }); + } else { + this.disposables.add(provider.onDidChangeCapabilities(() => readCapability())); + } + } + })); + this.disposables.add(this.model.onDidChangeRoots(() => this._onDidChangeRoots.fire())); } get roots(): ExplorerItem[] { @@ -107,24 +131,13 @@ export class ExplorerService implements IExplorerService { return fileEventsFilter; } - @memoize get model(): ExplorerModel { - const model = new ExplorerModel(this.contextService); - this.disposables.add(model); - this.disposables.add(this.fileService.onAfterOperation(e => this.onFileOperation(e))); - this.disposables.add(this.fileService.onFileChanges(e => this.onFileChanges(e))); - this.disposables.add(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(this.configurationService.getValue()))); - this.disposables.add(this.fileService.onDidChangeFileSystemProviderRegistrations(e => { - if (e.added && this.fileSystemProviderSchemes.has(e.scheme)) { - // A file system provider got re-registered, we should update all file stats since they might change (got read-only) - this.model.roots.forEach(r => r.forgetChildren()); - this._onDidChangeItem.fire({ recursive: true }); - } else { - this.fileSystemProviderSchemes.add(e.scheme); - } - })); - this.disposables.add(model.onDidChangeRoots(() => this._onDidChangeRoots.fire())); + shouldIgnoreCase(resource: URI): boolean { + const caseSensitive = this.fileSystemProviderCaseSensitivity.get(resource.scheme); + if (typeof caseSensitive === 'undefined') { + return hasToIgnoreCase(resource); + } - return model; + return !caseSensitive; } // IExplorerService methods @@ -187,7 +200,7 @@ export class ExplorerService implements IExplorerService { const stat = await this.fileService.resolve(rootUri, options); // Convert to model - const modelStat = ExplorerItem.create(this.fileService, stat, undefined, options.resolveTo); + const modelStat = ExplorerItem.create(this, this.fileService, stat, undefined, options.resolveTo); // Update Input with disk Stat ExplorerItem.mergeLocalWithDisk(modelStat, root); const item = root.find(resource); @@ -231,11 +244,11 @@ export class ExplorerService implements IExplorerService { const thenable: Promise = p.isDirectoryResolved ? Promise.resolve(undefined) : this.fileService.resolve(p.resource, { resolveMetadata }); thenable.then(stat => { if (stat) { - const modelStat = ExplorerItem.create(this.fileService, stat, p.parent); + const modelStat = ExplorerItem.create(this, this.fileService, stat, p.parent); ExplorerItem.mergeLocalWithDisk(modelStat, p); } - const childElement = ExplorerItem.create(this.fileService, addedElement, p.parent); + const childElement = ExplorerItem.create(this, this.fileService, addedElement, p.parent); // Make sure to remove any previous version of the file if any p.removeChild(childElement); p.addChild(childElement); diff --git a/src/vs/workbench/contrib/files/common/files.ts b/src/vs/workbench/contrib/files/common/files.ts index dea52047854..86a60a95d14 100644 --- a/src/vs/workbench/contrib/files/common/files.ts +++ b/src/vs/workbench/contrib/files/common/files.ts @@ -55,6 +55,7 @@ export interface IExplorerService { refresh(): void; setToCopy(stats: ExplorerItem[], cut: boolean): void; isCut(stat: ExplorerItem): boolean; + shouldIgnoreCase(resource: URI): boolean; /** * Selects and reveal the file element provided by the given resource if its found in the explorer. diff --git a/src/vs/workbench/contrib/files/test/electron-browser/explorerModel.test.ts b/src/vs/workbench/contrib/files/test/electron-browser/explorerModel.test.ts index b269b6eb84c..af77f1c87cd 100644 --- a/src/vs/workbench/contrib/files/test/electron-browser/explorerModel.test.ts +++ b/src/vs/workbench/contrib/files/test/electron-browser/explorerModel.test.ts @@ -10,9 +10,18 @@ import { join } from 'vs/base/common/path'; import { validateFileName } from 'vs/workbench/contrib/files/browser/fileActions'; import { ExplorerItem } from 'vs/workbench/contrib/files/common/explorerModel'; import { toResource } from 'vs/base/test/common/utils'; +import { hasToIgnoreCase } from 'vs/base/common/resources'; +import { IExplorerService } from 'vs/workbench/contrib/files/common/files'; + +class MockExplorerService { + shouldIgnoreCase(resource: URI) { + return hasToIgnoreCase(resource); + } +} +const mockExplorerService = new MockExplorerService() as IExplorerService; function createStat(this: any, path: string, name: string, isFolder: boolean, hasChildren: boolean, size: number, mtime: number): ExplorerItem { - return new ExplorerItem(toResource.call(this, path), undefined, isFolder, false, false, name, mtime); + return new ExplorerItem(toResource.call(this, path), mockExplorerService, undefined, isFolder, false, false, name, mtime); } suite('Files - View Model', function () { @@ -243,19 +252,19 @@ suite('Files - View Model', function () { }); test('Merge Local with Disk', function () { - const merge1 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), undefined, true, false, false, 'to', Date.now()); - const merge2 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), undefined, true, false, false, 'to', Date.now()); + const merge1 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), mockExplorerService, undefined, true, false, false, 'to', Date.now()); + const merge2 = new ExplorerItem(URI.file(join('C:\\', '/path/to')), mockExplorerService, undefined, true, false, false, 'to', Date.now()); // Merge Properties ExplorerItem.mergeLocalWithDisk(merge2, merge1); assert.strictEqual(merge1.mtime, merge2.mtime); // Merge Child when isDirectoryResolved=false is a no-op - merge2.addChild(new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), undefined, true, false, false, 'foo.html', Date.now())); + merge2.addChild(new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), mockExplorerService, undefined, true, false, false, 'foo.html', Date.now())); ExplorerItem.mergeLocalWithDisk(merge2, merge1); // Merge Child with isDirectoryResolved=true - const child = new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), undefined, true, false, false, 'foo.html', Date.now()); + const child = new ExplorerItem(URI.file(join('C:\\', '/path/to/foo.html')), mockExplorerService, undefined, true, false, false, 'foo.html', Date.now()); merge2.removeChild(child); merge2.addChild(child); (merge2)._isDirectoryResolved = true; diff --git a/src/vs/workbench/electron-browser/window.ts b/src/vs/workbench/electron-browser/window.ts index 389d3a6c14d..5e7ff1184b5 100644 --- a/src/vs/workbench/electron-browser/window.ts +++ b/src/vs/workbench/electron-browser/window.ts @@ -283,6 +283,7 @@ export class ElectronWindow extends Disposable { })); } + // Detect minimize / maximize this._register(Event.any( Event.map(Event.filter(this.electronService.onWindowMaximize, id => id === this.electronEnvironmentService.windowId), () => true), Event.map(Event.filter(this.electronService.onWindowUnmaximize, id => id === this.electronEnvironmentService.windowId), () => false) diff --git a/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts b/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts index 02fae436721..f49293fd2ab 100644 --- a/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts +++ b/src/vs/workbench/services/backup/test/electron-browser/backupFileService.test.ts @@ -51,14 +51,13 @@ class TestBackupEnvironmentService extends NativeWorkbenchEnvironmentService { constructor(backupPath: string) { super({ ...parseArgs(process.argv, OPTIONS), ...{ backupPath, 'user-data-dir': userdataDir } } as IWindowConfiguration, process.execPath, 0); } - } -class TestBackupFileService extends BackupFileService { +export class NodeTestBackupFileService extends BackupFileService { readonly fileService: IFileService; - constructor(workspace: URI, backupHome: string, workspacesJsonPath: string) { + constructor(workspaceBackupPath: string) { const environmentService = new TestBackupEnvironmentService(workspaceBackupPath); const fileService = new FileService(new NullLogService()); const diskFileSystemProvider = new DiskFileSystemProvider(new NullLogService()); @@ -76,10 +75,10 @@ class TestBackupFileService extends BackupFileService { } suite('BackupFileService', () => { - let service: TestBackupFileService; + let service: NodeTestBackupFileService; setup(async () => { - service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); + service = new NodeTestBackupFileService(workspaceBackupPath); // Delete any existing backups completely and then re-create it. await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); @@ -141,7 +140,7 @@ suite('BackupFileService', () => { test('should return whether a backup resource exists', async () => { await pfs.mkdirp(path.dirname(fooBackupPath)); fs.writeFileSync(fooBackupPath, 'foo'); - service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); + service = new NodeTestBackupFileService(workspaceBackupPath); const resource = await service.loadBackupResource(fooFile); assert.ok(resource); assert.equal(path.basename(resource!.fsPath), path.basename(fooBackupPath)); @@ -528,10 +527,10 @@ suite('BackupFileService', () => { suite('BackupFilesModel', () => { - let service: TestBackupFileService; + let service: NodeTestBackupFileService; setup(async () => { - service = new TestBackupFileService(workspaceResource, backupHome, workspacesJsonPath); + service = new NodeTestBackupFileService(workspaceBackupPath); // Delete any existing backups completely and then re-create it. await pfs.rimraf(backupHome, pfs.RimRafMode.MOVE); diff --git a/src/vs/workbench/services/configuration/browser/configuration.ts b/src/vs/workbench/services/configuration/browser/configuration.ts index d2450a267f7..455ea3af671 100644 --- a/src/vs/workbench/services/configuration/browser/configuration.ts +++ b/src/vs/workbench/services/configuration/browser/configuration.ts @@ -7,12 +7,12 @@ import { URI } from 'vs/base/common/uri'; import * as resources from 'vs/base/common/resources'; import { Event, Emitter } from 'vs/base/common/event'; import * as errors from 'vs/base/common/errors'; -import { Disposable, IDisposable, dispose, toDisposable } from 'vs/base/common/lifecycle'; -import { RunOnceScheduler } from 'vs/base/common/async'; +import { Disposable, IDisposable, dispose, toDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { RunOnceScheduler, runWhenIdle } from 'vs/base/common/async'; import { FileChangeType, FileChangesEvent, IFileService, whenProviderRegistered } from 'vs/platform/files/common/files'; import { ConfigurationModel, ConfigurationModelParser } from 'vs/platform/configuration/common/configurationModels'; import { WorkspaceConfigurationModelParser, StandaloneConfigurationModelParser } from 'vs/workbench/services/configuration/common/configurationModels'; -import { FOLDER_SETTINGS_PATH, TASKS_CONFIGURATION_KEY, FOLDER_SETTINGS_NAME, LAUNCH_CONFIGURATION_KEY, IConfigurationCache, ConfigurationKey, REMOTE_MACHINE_SCOPES, FOLDER_SCOPES, WORKSPACE_SCOPES } from 'vs/workbench/services/configuration/common/configuration'; +import { TASKS_CONFIGURATION_KEY, FOLDER_SETTINGS_NAME, LAUNCH_CONFIGURATION_KEY, IConfigurationCache, ConfigurationKey, REMOTE_MACHINE_SCOPES, FOLDER_SCOPES, WORKSPACE_SCOPES } from 'vs/workbench/services/configuration/common/configuration'; import { IStoredWorkspaceFolder, IWorkspaceIdentifier } from 'vs/platform/workspaces/common/workspaces'; import { JSONEditingService } from 'vs/workbench/services/configuration/common/jsonEditingService'; import { WorkbenchState, IWorkspaceFolder } from 'vs/platform/workspace/common/workspace'; @@ -26,28 +26,61 @@ import { hash } from 'vs/base/common/hash'; export class UserConfiguration extends Disposable { - private readonly parser: ConfigurationModelParser; - private readonly reloadConfigurationScheduler: RunOnceScheduler; - protected readonly _onDidChangeConfiguration: Emitter = this._register(new Emitter()); + private readonly _onDidInitializeCompleteConfiguration: Emitter = this._register(new Emitter()); + private readonly _onDidChangeConfiguration: Emitter = this._register(new Emitter()); readonly onDidChangeConfiguration: Event = this._onDidChangeConfiguration.event; + private readonly userConfiguration: MutableDisposable = this._register(new MutableDisposable()); + private readonly reloadConfigurationScheduler: RunOnceScheduler; + constructor( private readonly userSettingsResource: URI, private readonly scopes: ConfigurationScope[] | undefined, private readonly fileService: IFileService ) { super(); - - this.parser = new ConfigurationModelParser(this.userSettingsResource.toString(), this.scopes); + this.userConfiguration.value = new UserSettings(this.userSettingsResource, this.scopes, this.fileService); + this._register(this.userConfiguration.value.onDidChange(() => this.reloadConfigurationScheduler.schedule())); this.reloadConfigurationScheduler = this._register(new RunOnceScheduler(() => this.reload().then(configurationModel => this._onDidChangeConfiguration.fire(configurationModel)), 50)); - this._register(Event.filter(this.fileService.onFileChanges, e => e.contains(this.userSettingsResource))(() => this.reloadConfigurationScheduler.schedule())); + + runWhenIdle(() => this._onDidInitializeCompleteConfiguration.fire(), 5000); + this._register(Event.once(this._onDidInitializeCompleteConfiguration.event)(() => this.reloadConfigurationScheduler.schedule())); } async initialize(): Promise { - return this.reload(); + return this.userConfiguration.value!.loadConfiguration(); } async reload(): Promise { + if (!(this.userConfiguration.value instanceof FileServiceBasedConfigurationWithNames)) { + this.userConfiguration.value = new FileServiceBasedConfigurationWithNames(resources.dirname(this.userSettingsResource), [FOLDER_SETTINGS_NAME, TASKS_CONFIGURATION_KEY], this.scopes, this.fileService); + this._register(this.userConfiguration.value.onDidChange(() => this.reloadConfigurationScheduler.schedule())); + } + return this.userConfiguration.value!.loadConfiguration(); + } + + reprocess(): ConfigurationModel { + return this.userConfiguration.value!.reprocess(); + } +} + +class UserSettings extends Disposable { + + private readonly parser: ConfigurationModelParser; + protected readonly _onDidChange: Emitter = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor( + private readonly userSettingsResource: URI, + private readonly scopes: ConfigurationScope[] | undefined, + private readonly fileService: IFileService + ) { + super(); + this.parser = new ConfigurationModelParser(this.userSettingsResource.toString(), this.scopes); + this._register(Event.filter(this.fileService.onFileChanges, e => e.contains(this.userSettingsResource))(() => this._onDidChange.fire())); + } + + async loadConfiguration(): Promise { try { const content = await this.fileService.readFile(this.userSettingsResource); this.parser.parseContent(content.value.toString() || '{}'); @@ -63,6 +96,127 @@ export class UserConfiguration extends Disposable { } } +class FileServiceBasedConfigurationWithNames extends Disposable { + + private _folderSettingsModelParser: ConfigurationModelParser; + private _standAloneConfigurations: ConfigurationModel[]; + private _cache: ConfigurationModel; + + protected readonly configurationResources: URI[]; + protected changeEventTriggerScheduler: RunOnceScheduler; + protected readonly _onDidChange: Emitter = this._register(new Emitter()); + readonly onDidChange: Event = this._onDidChange.event; + + constructor(protected readonly configurationFolder: URI, + private readonly configurationNames: string[], + private readonly scopes: ConfigurationScope[] | undefined, + private fileService: IFileService) { + super(); + this.configurationResources = this.configurationNames.map(name => resources.joinPath(this.configurationFolder, `${name}.json`)); + this._folderSettingsModelParser = new ConfigurationModelParser(this.configurationFolder.toString(), this.scopes); + this._standAloneConfigurations = []; + this._cache = new ConfigurationModel(); + + this.changeEventTriggerScheduler = this._register(new RunOnceScheduler(() => this._onDidChange.fire(), 50)); + this._register(this.fileService.onFileChanges((e) => this.handleFileEvents(e))); + } + + async loadConfiguration(): Promise { + const configurationContents = await Promise.all(this.configurationResources.map(async resource => { + try { + const content = await this.fileService.readFile(resource); + return content.value.toString(); + } catch (error) { + const exists = await this.fileService.exists(resource); + if (exists) { + errors.onUnexpectedError(error); + } + } + return undefined; + })); + + // reset + this._standAloneConfigurations = []; + this._folderSettingsModelParser.parseContent(''); + + // parse + if (configurationContents[0]) { + this._folderSettingsModelParser.parseContent(configurationContents[0]); + } + for (let index = 1; index < configurationContents.length; index++) { + const contents = configurationContents[index]; + if (contents) { + const standAloneConfigurationModelParser = new StandaloneConfigurationModelParser(this.configurationResources[index].toString(), this.configurationNames[index]); + standAloneConfigurationModelParser.parseContent(contents); + this._standAloneConfigurations.push(standAloneConfigurationModelParser.configurationModel); + } + } + + // Consolidate (support *.json files in the workspace settings folder) + this.consolidate(); + + return this._cache; + } + + reprocess(): ConfigurationModel { + const oldContents = this._folderSettingsModelParser.configurationModel.contents; + this._folderSettingsModelParser.parse(); + if (!equals(oldContents, this._folderSettingsModelParser.configurationModel.contents)) { + this.consolidate(); + } + return this._cache; + } + + private consolidate(): void { + this._cache = this._folderSettingsModelParser.configurationModel.merge(...this._standAloneConfigurations); + } + + protected async handleFileEvents(event: FileChangesEvent): Promise { + const events = event.changes; + let affectedByChanges = false; + + // Find changes that affect workspace configuration files + for (let i = 0, len = events.length; i < len; i++) { + const resource = events[i].resource; + const basename = resources.basename(resource); + const isJson = extname(basename) === '.json'; + const isConfigurationFolderDeleted = (events[i].type === FileChangeType.DELETED && resources.isEqual(resource, this.configurationFolder)); + + if (!isJson && !isConfigurationFolderDeleted) { + continue; // only JSON files or the actual settings folder + } + + const folderRelativePath = this.toFolderRelativePath(resource); + if (!folderRelativePath) { + continue; // event is not inside folder + } + + // Handle case where ".vscode" got deleted + if (isConfigurationFolderDeleted) { + affectedByChanges = true; + break; + } + + // only valid workspace config files + if (this.configurationResources.some(configurationResource => resources.isEqual(configurationResource, resource))) { + affectedByChanges = true; + break; + } + } + + if (affectedByChanges) { + this.changeEventTriggerScheduler.schedule(); + } + } + + private toFolderRelativePath(resource: URI): string | undefined { + if (resources.isEqualOrParent(resource, this.configurationFolder)) { + return resources.relativePath(this.configurationFolder, resource); + } + return undefined; + } +} + export class RemoteUserConfiguration extends Disposable { private readonly _cachedConfiguration: CachedRemoteUserConfiguration; @@ -546,125 +700,12 @@ export interface IFolderConfiguration extends IDisposable { reprocess(): ConfigurationModel; } -class FileServiceBasedFolderConfiguration extends Disposable implements IFolderConfiguration { +class FileServiceBasedFolderConfiguration extends FileServiceBasedConfigurationWithNames implements IFolderConfiguration { - private _folderSettingsModelParser: ConfigurationModelParser; - private _standAloneConfigurations: ConfigurationModel[]; - private _cache: ConfigurationModel; - - private readonly configurationNames: string[]; - protected readonly configurationResources: URI[]; - private changeEventTriggerScheduler: RunOnceScheduler; - protected readonly _onDidChange: Emitter = this._register(new Emitter()); - readonly onDidChange: Event = this._onDidChange.event; - - constructor(protected readonly configurationFolder: URI, workbenchState: WorkbenchState, private fileService: IFileService) { - super(); - - this.configurationNames = [FOLDER_SETTINGS_NAME /*First one should be settings */, TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY]; - this.configurationResources = this.configurationNames.map(name => resources.joinPath(this.configurationFolder, `${name}.json`)); - this._folderSettingsModelParser = new ConfigurationModelParser(FOLDER_SETTINGS_PATH, WorkbenchState.WORKSPACE === workbenchState ? FOLDER_SCOPES : WORKSPACE_SCOPES); - this._standAloneConfigurations = []; - this._cache = new ConfigurationModel(); - - this.changeEventTriggerScheduler = this._register(new RunOnceScheduler(() => this._onDidChange.fire(), 50)); - this._register(fileService.onFileChanges(e => this.handleWorkspaceFileEvents(e))); + constructor(configurationFolder: URI, workbenchState: WorkbenchState, fileService: IFileService) { + super(configurationFolder, [FOLDER_SETTINGS_NAME /*First one should be settings */, TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY], WorkbenchState.WORKSPACE === workbenchState ? FOLDER_SCOPES : WORKSPACE_SCOPES, fileService); } - async loadConfiguration(): Promise { - const configurationContents = await Promise.all(this.configurationResources.map(async resource => { - try { - const content = await this.fileService.readFile(resource); - return content.value.toString(); - } catch (error) { - const exists = await this.fileService.exists(resource); - if (exists) { - errors.onUnexpectedError(error); - } - } - return undefined; - })); - - // reset - this._standAloneConfigurations = []; - this._folderSettingsModelParser.parseContent(''); - - // parse - if (configurationContents[0]) { - this._folderSettingsModelParser.parseContent(configurationContents[0]); - } - for (let index = 1; index < configurationContents.length; index++) { - const contents = configurationContents[index]; - if (contents) { - const standAloneConfigurationModelParser = new StandaloneConfigurationModelParser(this.configurationResources[index].toString(), this.configurationNames[index]); - standAloneConfigurationModelParser.parseContent(contents); - this._standAloneConfigurations.push(standAloneConfigurationModelParser.configurationModel); - } - } - - // Consolidate (support *.json files in the workspace settings folder) - this.consolidate(); - - return this._cache; - } - - reprocess(): ConfigurationModel { - const oldContents = this._folderSettingsModelParser.configurationModel.contents; - this._folderSettingsModelParser.parse(); - if (!equals(oldContents, this._folderSettingsModelParser.configurationModel.contents)) { - this.consolidate(); - } - return this._cache; - } - - private consolidate(): void { - this._cache = this._folderSettingsModelParser.configurationModel.merge(...this._standAloneConfigurations); - } - - private handleWorkspaceFileEvents(event: FileChangesEvent): void { - const events = event.changes; - let affectedByChanges = false; - - // Find changes that affect workspace configuration files - for (let i = 0, len = events.length; i < len; i++) { - const resource = events[i].resource; - const basename = resources.basename(resource); - const isJson = extname(basename) === '.json'; - const isConfigurationFolderDeleted = (events[i].type === FileChangeType.DELETED && resources.isEqual(resource, this.configurationFolder)); - - if (!isJson && !isConfigurationFolderDeleted) { - continue; // only JSON files or the actual settings folder - } - - const folderRelativePath = this.toFolderRelativePath(resource); - if (!folderRelativePath) { - continue; // event is not inside folder - } - - // Handle case where ".vscode" got deleted - if (isConfigurationFolderDeleted) { - affectedByChanges = true; - break; - } - - // only valid workspace config files - if (this.configurationResources.some(configurationResource => resources.isEqual(configurationResource, resource))) { - affectedByChanges = true; - break; - } - } - - if (affectedByChanges) { - this.changeEventTriggerScheduler.schedule(); - } - } - - private toFolderRelativePath(resource: URI): string | undefined { - if (resources.isEqualOrParent(resource, this.configurationFolder)) { - return resources.relativePath(this.configurationFolder, resource); - } - return undefined; - } } class CachedFolderConfiguration extends Disposable implements IFolderConfiguration { diff --git a/src/vs/workbench/services/configuration/common/configuration.ts b/src/vs/workbench/services/configuration/common/configuration.ts index 069871e52db..7cec0a25421 100644 --- a/src/vs/workbench/services/configuration/common/configuration.ts +++ b/src/vs/workbench/services/configuration/common/configuration.ts @@ -28,6 +28,8 @@ export const LAUNCH_CONFIGURATION_KEY = 'launch'; export const WORKSPACE_STANDALONE_CONFIGURATIONS = Object.create(null); WORKSPACE_STANDALONE_CONFIGURATIONS[TASKS_CONFIGURATION_KEY] = `${FOLDER_CONFIG_FOLDER_NAME}/${TASKS_CONFIGURATION_KEY}.json`; WORKSPACE_STANDALONE_CONFIGURATIONS[LAUNCH_CONFIGURATION_KEY] = `${FOLDER_CONFIG_FOLDER_NAME}/${LAUNCH_CONFIGURATION_KEY}.json`; +export const USER_STANDALONE_CONFIGURATIONS = Object.create(null); +USER_STANDALONE_CONFIGURATIONS[TASKS_CONFIGURATION_KEY] = `${TASKS_CONFIGURATION_KEY}.json`; export type ConfigurationKey = { type: 'user' | 'workspaces' | 'folder', key: string }; diff --git a/src/vs/workbench/services/configuration/common/configurationEditingService.ts b/src/vs/workbench/services/configuration/common/configurationEditingService.ts index 68bb46913a1..9253110fde1 100644 --- a/src/vs/workbench/services/configuration/common/configurationEditingService.ts +++ b/src/vs/workbench/services/configuration/common/configurationEditingService.ts @@ -5,6 +5,7 @@ import * as nls from 'vs/nls'; import { URI } from 'vs/base/common/uri'; +import * as resources from 'vs/base/common/resources'; import * as json from 'vs/base/common/json'; import * as strings from 'vs/base/common/strings'; import { setProperty } from 'vs/base/common/jsonEdit'; @@ -19,7 +20,7 @@ import { IWorkspaceContextService, WorkbenchState } from 'vs/platform/workspace/ import { IEnvironmentService } from 'vs/platform/environment/common/environment'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; import { IConfigurationService, IConfigurationOverrides, keyFromOverrideIdentifier } from 'vs/platform/configuration/common/configuration'; -import { FOLDER_SETTINGS_PATH, WORKSPACE_STANDALONE_CONFIGURATIONS, TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY } from 'vs/workbench/services/configuration/common/configuration'; +import { FOLDER_SETTINGS_PATH, WORKSPACE_STANDALONE_CONFIGURATIONS, TASKS_CONFIGURATION_KEY, LAUNCH_CONFIGURATION_KEY, USER_STANDALONE_CONFIGURATIONS } from 'vs/workbench/services/configuration/common/configuration'; import { IFileService } from 'vs/platform/files/common/files'; import { ITextModelService, IResolvedTextEditorModel } from 'vs/editor/common/services/resolverService'; import { OVERRIDE_PROPERTY_PATTERN, IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry'; @@ -430,8 +431,8 @@ export class ConfigurationEditingService { } if (operation.workspaceStandAloneConfigurationKey) { - // Global tasks and launches are not supported - if (target === EditableConfigurationTarget.USER_LOCAL || target === EditableConfigurationTarget.USER_REMOTE) { + // Global launches are not supported + if ((operation.workspaceStandAloneConfigurationKey !== TASKS_CONFIGURATION_KEY) && (target === EditableConfigurationTarget.USER_LOCAL || target === EditableConfigurationTarget.USER_REMOTE)) { return this.reject(ConfigurationEditingErrorCode.ERROR_INVALID_USER_TARGET, target, operation); } } @@ -497,9 +498,10 @@ export class ConfigurationEditingService { // Check for standalone workspace configurations if (config.key) { - const standaloneConfigurationKeys = Object.keys(WORKSPACE_STANDALONE_CONFIGURATIONS); + const standaloneConfigurationMap = target === EditableConfigurationTarget.USER_LOCAL ? USER_STANDALONE_CONFIGURATIONS : WORKSPACE_STANDALONE_CONFIGURATIONS; + const standaloneConfigurationKeys = Object.keys(standaloneConfigurationMap); for (const key of standaloneConfigurationKeys) { - const resource = this.getConfigurationFileResource(target, config, WORKSPACE_STANDALONE_CONFIGURATIONS[key], overrides.resource); + const resource = this.getConfigurationFileResource(target, config, standaloneConfigurationMap[key], overrides.resource); // Check for prefix if (config.key === key) { @@ -536,7 +538,11 @@ export class ConfigurationEditingService { private getConfigurationFileResource(target: EditableConfigurationTarget, config: IConfigurationValue, relativePath: string, resource: URI | null | undefined): URI | null { if (target === EditableConfigurationTarget.USER_LOCAL) { - return this.environmentService.settingsResource; + if (relativePath) { + return resources.joinPath(resources.dirname(this.environmentService.settingsResource), relativePath); + } else { + return this.environmentService.settingsResource; + } } if (target === EditableConfigurationTarget.USER_REMOTE) { return this.remoteSettingsResource; diff --git a/src/vs/workbench/services/configuration/test/electron-browser/configurationEditingService.test.ts b/src/vs/workbench/services/configuration/test/electron-browser/configurationEditingService.test.ts index d315118d91c..401d5a63768 100644 --- a/src/vs/workbench/services/configuration/test/electron-browser/configurationEditingService.test.ts +++ b/src/vs/workbench/services/configuration/test/electron-browser/configurationEditingService.test.ts @@ -18,7 +18,7 @@ import * as uuid from 'vs/base/common/uuid'; import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from 'vs/platform/configuration/common/configurationRegistry'; import { WorkspaceService } from 'vs/workbench/services/configuration/browser/configurationService'; import { ConfigurationEditingService, ConfigurationEditingError, ConfigurationEditingErrorCode, EditableConfigurationTarget } from 'vs/workbench/services/configuration/common/configurationEditingService'; -import { WORKSPACE_STANDALONE_CONFIGURATIONS, FOLDER_SETTINGS_PATH } from 'vs/workbench/services/configuration/common/configuration'; +import { WORKSPACE_STANDALONE_CONFIGURATIONS, FOLDER_SETTINGS_PATH, USER_STANDALONE_CONFIGURATIONS } from 'vs/workbench/services/configuration/common/configuration'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { ITextFileService } from 'vs/workbench/services/textfile/common/textfiles'; @@ -60,6 +60,7 @@ suite('ConfigurationEditingService', () => { let parentDir: string; let workspaceDir: string; let globalSettingsFile: string; + let globalTasksFile: string; let workspaceSettingsDir; suiteSetup(() => { @@ -94,6 +95,7 @@ suite('ConfigurationEditingService', () => { parentDir = path.join(os.tmpdir(), 'vsctests', id); workspaceDir = path.join(parentDir, 'workspaceconfig', id); globalSettingsFile = path.join(workspaceDir, 'settings.json'); + globalTasksFile = path.join(workspaceDir, 'tasks.json'); workspaceSettingsDir = path.join(workspaceDir, '.vscode'); return await mkdirp(workspaceSettingsDir, 493); @@ -149,12 +151,6 @@ suite('ConfigurationEditingService', () => { (error: ConfigurationEditingError) => assert.equal(error.code, ConfigurationEditingErrorCode.ERROR_UNKNOWN_KEY)); }); - test('errors cases - invalid target', () => { - return testObject.writeConfiguration(EditableConfigurationTarget.USER_LOCAL, { key: 'tasks.something', value: 'value' }) - .then(() => assert.fail('Should fail with ERROR_INVALID_TARGET'), - (error: ConfigurationEditingError) => assert.equal(error.code, ConfigurationEditingErrorCode.ERROR_INVALID_USER_TARGET)); - }); - test('errors cases - no workspace', () => { return setUpServices(true) .then(() => testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'configurationEditing.service.testSetting', value: 'value' })) @@ -162,11 +158,19 @@ suite('ConfigurationEditingService', () => { (error: ConfigurationEditingError) => assert.equal(error.code, ConfigurationEditingErrorCode.ERROR_NO_WORKSPACE_OPENED)); }); - test('errors cases - invalid configuration', () => { - fs.writeFileSync(globalSettingsFile, ',,,,,,,,,,,,,,'); - return testObject.writeConfiguration(EditableConfigurationTarget.USER_LOCAL, { key: 'configurationEditing.service.testSetting', value: 'value' }) + function errorCasesInvalidConfig(file: string, key: string) { + fs.writeFileSync(file, ',,,,,,,,,,,,,,'); + return testObject.writeConfiguration(EditableConfigurationTarget.USER_LOCAL, { key, value: 'value' }) .then(() => assert.fail('Should fail with ERROR_INVALID_CONFIGURATION'), (error: ConfigurationEditingError) => assert.equal(error.code, ConfigurationEditingErrorCode.ERROR_INVALID_CONFIGURATION)); + } + + test('errors cases - invalid configuration', () => { + return errorCasesInvalidConfig(globalSettingsFile, 'configurationEditing.service.testSetting'); + }); + + test('errors cases - invalid global tasks configuration', () => { + return errorCasesInvalidConfig(globalTasksFile, 'tasks.configurationEditing.service.testSetting'); }); test('errors cases - dirty', () => { @@ -271,44 +275,89 @@ suite('ConfigurationEditingService', () => { }); }); - test('write workspace standalone setting - empty file', () => { - return testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'tasks.service.testSetting', value: 'value' }) + function writeStandaloneSettingEmptyFile(configTarget: EditableConfigurationTarget, pathMap: any) { + return testObject.writeConfiguration(configTarget, { key: 'tasks.service.testSetting', value: 'value' }) .then(() => { - const target = path.join(workspaceDir, WORKSPACE_STANDALONE_CONFIGURATIONS['tasks']); + const target = path.join(workspaceDir, pathMap['tasks']); const contents = fs.readFileSync(target).toString('utf8'); const parsed = json.parse(contents); assert.equal(parsed['service.testSetting'], 'value'); }); + } + + test('write workspace standalone setting - empty file', () => { + return writeStandaloneSettingEmptyFile(EditableConfigurationTarget.WORKSPACE, WORKSPACE_STANDALONE_CONFIGURATIONS); }); - test('write workspace standalone setting - existing file', () => { - const target = path.join(workspaceDir, WORKSPACE_STANDALONE_CONFIGURATIONS['launch']); + test('write user standalone setting - empty file', () => { + return writeStandaloneSettingEmptyFile(EditableConfigurationTarget.USER_LOCAL, USER_STANDALONE_CONFIGURATIONS); + }); + + function writeStandaloneSettingExitingFile(configTarget: EditableConfigurationTarget, pathMap: any) { + const target = path.join(workspaceDir, pathMap['tasks']); fs.writeFileSync(target, '{ "my.super.setting": "my.super.value" }'); - return testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'launch.service.testSetting', value: 'value' }) + return testObject.writeConfiguration(configTarget, { key: 'tasks.service.testSetting', value: 'value' }) .then(() => { const contents = fs.readFileSync(target).toString('utf8'); const parsed = json.parse(contents); assert.equal(parsed['service.testSetting'], 'value'); assert.equal(parsed['my.super.setting'], 'my.super.value'); }); + } + + test('write workspace standalone setting - existing file', () => { + return writeStandaloneSettingExitingFile(EditableConfigurationTarget.WORKSPACE, WORKSPACE_STANDALONE_CONFIGURATIONS); }); + test('write user standalone setting - existing file', () => { + return writeStandaloneSettingExitingFile(EditableConfigurationTarget.USER_LOCAL, USER_STANDALONE_CONFIGURATIONS); + }); + + function writeStandaloneSettingEmptyFileFullJson(configTarget: EditableConfigurationTarget, pathMap: any) { + return testObject.writeConfiguration(configTarget, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask' }] } }) + .then(() => { + const target = path.join(workspaceDir, pathMap['tasks']); + const contents = fs.readFileSync(target).toString('utf8'); + const parsed = json.parse(contents); + + assert.equal(parsed['version'], '1.0.0'); + assert.equal(parsed['tasks'][0]['taskName'], 'myTask'); + }); + } + test('write workspace standalone setting - empty file - full JSON', () => { - return testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask' }] } }) + return writeStandaloneSettingEmptyFileFullJson(EditableConfigurationTarget.WORKSPACE, WORKSPACE_STANDALONE_CONFIGURATIONS); + }); + + test('write user standalone setting - empty file - full JSON', () => { + return writeStandaloneSettingEmptyFileFullJson(EditableConfigurationTarget.USER_LOCAL, USER_STANDALONE_CONFIGURATIONS); + }); + + function writeStandaloneSettingExistingFileFullJson(configTarget: EditableConfigurationTarget, pathMap: any) { + const target = path.join(workspaceDir, pathMap['tasks']); + fs.writeFileSync(target, '{ "my.super.setting": "my.super.value" }'); + return testObject.writeConfiguration(configTarget, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask' }] } }) .then(() => { - const target = path.join(workspaceDir, WORKSPACE_STANDALONE_CONFIGURATIONS['tasks']); const contents = fs.readFileSync(target).toString('utf8'); const parsed = json.parse(contents); assert.equal(parsed['version'], '1.0.0'); assert.equal(parsed['tasks'][0]['taskName'], 'myTask'); }); - }); + } test('write workspace standalone setting - existing file - full JSON', () => { - const target = path.join(workspaceDir, WORKSPACE_STANDALONE_CONFIGURATIONS['tasks']); - fs.writeFileSync(target, '{ "my.super.setting": "my.super.value" }'); - return testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask' }] } }) + return writeStandaloneSettingExistingFileFullJson(EditableConfigurationTarget.WORKSPACE, WORKSPACE_STANDALONE_CONFIGURATIONS); + }); + + test('write user standalone setting - existing file - full JSON', () => { + return writeStandaloneSettingExistingFileFullJson(EditableConfigurationTarget.USER_LOCAL, USER_STANDALONE_CONFIGURATIONS); + }); + + function writeStandaloneSettingExistingFileWithJsonErrorFullJson(configTarget: EditableConfigurationTarget, pathMap: any) { + const target = path.join(workspaceDir, pathMap['tasks']); + fs.writeFileSync(target, '{ "my.super.setting": '); // invalid JSON + return testObject.writeConfiguration(configTarget, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask' }] } }) .then(() => { const contents = fs.readFileSync(target).toString('utf8'); const parsed = json.parse(contents); @@ -316,23 +365,18 @@ suite('ConfigurationEditingService', () => { assert.equal(parsed['version'], '1.0.0'); assert.equal(parsed['tasks'][0]['taskName'], 'myTask'); }); - }); + } test('write workspace standalone setting - existing file with JSON errors - full JSON', () => { - const target = path.join(workspaceDir, WORKSPACE_STANDALONE_CONFIGURATIONS['tasks']); - fs.writeFileSync(target, '{ "my.super.setting": '); // invalid JSON - return testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask' }] } }) - .then(() => { - const contents = fs.readFileSync(target).toString('utf8'); - const parsed = json.parse(contents); - - assert.equal(parsed['version'], '1.0.0'); - assert.equal(parsed['tasks'][0]['taskName'], 'myTask'); - }); + return writeStandaloneSettingExistingFileWithJsonErrorFullJson(EditableConfigurationTarget.WORKSPACE, WORKSPACE_STANDALONE_CONFIGURATIONS); }); - test('write workspace standalone setting should replace complete file', () => { - const target = path.join(workspaceDir, WORKSPACE_STANDALONE_CONFIGURATIONS['tasks']); + test('write user standalone setting - existing file with JSON errors - full JSON', () => { + return writeStandaloneSettingExistingFileWithJsonErrorFullJson(EditableConfigurationTarget.USER_LOCAL, USER_STANDALONE_CONFIGURATIONS); + }); + + function writeStandaloneSettingShouldReplace(configTarget: EditableConfigurationTarget, pathMap: any) { + const target = path.join(workspaceDir, pathMap['tasks']); fs.writeFileSync(target, `{ "version": "1.0.0", "tasks": [ @@ -344,11 +388,19 @@ suite('ConfigurationEditingService', () => { } ] }`); - return testObject.writeConfiguration(EditableConfigurationTarget.WORKSPACE, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask1' }] } }) + return testObject.writeConfiguration(configTarget, { key: 'tasks', value: { 'version': '1.0.0', tasks: [{ 'taskName': 'myTask1' }] } }) .then(() => { const actual = fs.readFileSync(target).toString('utf8'); const expected = JSON.stringify({ 'version': '1.0.0', tasks: [{ 'taskName': 'myTask1' }] }, null, '\t'); assert.equal(actual, expected); }); + } + + test('write workspace standalone setting should replace complete file', () => { + return writeStandaloneSettingShouldReplace(EditableConfigurationTarget.WORKSPACE, WORKSPACE_STANDALONE_CONFIGURATIONS); + }); + + test('write user standalone setting should replace complete file', () => { + return writeStandaloneSettingShouldReplace(EditableConfigurationTarget.USER_LOCAL, USER_STANDALONE_CONFIGURATIONS); }); }); diff --git a/src/vs/workbench/services/configuration/test/electron-browser/configurationService.test.ts b/src/vs/workbench/services/configuration/test/electron-browser/configurationService.test.ts index e0d180d5470..f78eacb60af 100644 --- a/src/vs/workbench/services/configuration/test/electron-browser/configurationService.test.ts +++ b/src/vs/workbench/services/configuration/test/electron-browser/configurationService.test.ts @@ -47,6 +47,7 @@ import { FileUserDataProvider } from 'vs/workbench/services/userData/common/file import { IKeybindingEditingService, KeybindingsEditingService } from 'vs/workbench/services/keybinding/common/keybindingEditing'; import { NativeWorkbenchEnvironmentService } from 'vs/workbench/services/environment/electron-browser/environmentService'; import { IWorkbenchEnvironmentService } from 'vs/workbench/services/environment/common/environmentService'; +import { timeout } from 'vs/base/common/async'; class TestEnvironmentService extends NativeWorkbenchEnvironmentService { @@ -717,7 +718,7 @@ suite('WorkspaceService - Initialization', () => { suite('WorkspaceConfigurationService - Folder', () => { - let workspaceName = `testWorkspace${uuid.generateUuid()}`, parentResource: string, workspaceDir: string, testObject: IConfigurationService, globalSettingsFile: string; + let workspaceName = `testWorkspace${uuid.generateUuid()}`, parentResource: string, workspaceDir: string, testObject: IConfigurationService, globalSettingsFile: string, globalTasksFile: string; const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); suiteSetup(() => { @@ -756,6 +757,7 @@ suite('WorkspaceConfigurationService - Folder', () => { parentResource = parentDir; workspaceDir = folderDir; globalSettingsFile = path.join(parentDir, 'settings.json'); + globalTasksFile = path.join(parentDir, 'tasks.json'); const instantiationService = workbenchInstantiationService(); const environmentService = new TestEnvironmentService(URI.file(parentDir)); @@ -1074,6 +1076,17 @@ suite('WorkspaceConfigurationService - Folder', () => { .then(() => assert.ok(target.called)); }); + test('no change event when there are no global tasks', async () => { + const target = sinon.spy(); + testObject.onDidChangeConfiguration(target); + await timeout(500); + assert.ok(target.notCalled); + }); + + test('change event when there are global tasks', () => { + fs.writeFileSync(globalTasksFile, '{ "version": "1.0.0", "tasks": [{ "taskName": "myTask" }'); + return new Promise((c) => testObject.onDidChangeConfiguration(() => c())); + }); }); suite('WorkspaceConfigurationService-Multiroot', () => { @@ -1430,7 +1443,7 @@ suite('WorkspaceConfigurationService-Multiroot', () => { }); }); - test('inspect tasks configuration', () => { + test('inspect tasks configuration', async () => { const expectedTasksConfiguration = { 'version': '2.0.0', 'tasks': [ @@ -1445,12 +1458,10 @@ suite('WorkspaceConfigurationService-Multiroot', () => { } ] }; - return jsonEditingServce.write(workspaceContextService.getWorkspace().configuration!, [{ key: 'tasks', value: expectedTasksConfiguration }], true) - .then(() => testObject.reloadConfiguration()) - .then(() => { - const actual = testObject.inspect('tasks').workspaceValue; - assert.deepEqual(actual, expectedTasksConfiguration); - }); + await jsonEditingServce.write(workspaceContextService.getWorkspace().configuration!, [{ key: 'tasks', value: expectedTasksConfiguration }], true); + await testObject.reloadConfiguration(); + const actual = testObject.inspect('tasks').workspaceValue; + assert.deepEqual(actual, expectedTasksConfiguration); }); test('update user configuration', () => { diff --git a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts index ccf554c339e..abf37eb4fd6 100644 --- a/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorGroupsService.test.ts @@ -19,10 +19,14 @@ import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtil import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; import { CancellationToken } from 'vs/base/common/cancellation'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; + +const TEST_EDITOR_ID = 'MyFileEditorForEditorGroupService'; +const TEST_EDITOR_INPUT_ID = 'testEditorInputForEditorGroupService'; class TestEditorControl extends BaseEditor { - constructor(@ITelemetryService telemetryService: ITelemetryService) { super('MyFileEditorForEditorGroupService', NullTelemetryService, new TestThemeService(), new TestStorageService()); } + constructor(@ITelemetryService telemetryService: ITelemetryService) { super(TEST_EDITOR_ID, NullTelemetryService, new TestThemeService(), new TestStorageService()); } async setInput(input: EditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { super.setInput(input, options, token); @@ -30,7 +34,7 @@ class TestEditorControl extends BaseEditor { await input.resolve(); } - getId(): string { return 'MyFileEditorForEditorGroupService'; } + getId(): string { return TEST_EDITOR_ID; } layout(): void { } createEditor(): any { } } @@ -39,7 +43,7 @@ class TestEditorInput extends EditorInput implements IFileEditorInput { constructor(private resource: URI) { super(); } - getTypeId() { return 'testEditorInputForEditorGroupService'; } + getTypeId() { return TEST_EDITOR_INPUT_ID; } resolve(): Promise { return Promise.resolve(null); } matches(other: TestEditorInput): boolean { return other && this.resource.toString() === other.resource.toString() && other instanceof TestEditorInput; } setEncoding(encoding: string) { } @@ -53,8 +57,9 @@ class TestEditorInput extends EditorInput implements IFileEditorInput { suite('EditorGroupsService', () => { - function registerTestEditorInput(): void { + let disposables: IDisposable[] = []; + setup(() => { interface ISerializedTestEditorInput { resource: string; } @@ -81,11 +86,14 @@ suite('EditorGroupsService', () => { } } - (Registry.as(EditorExtensions.EditorInputFactories)).registerEditorInputFactory('testEditorInputForGroupsService', TestEditorInputFactory); - (Registry.as(Extensions.Editors)).registerEditor(EditorDescriptor.create(TestEditorControl, 'MyTestEditorForGroupsService', 'My Test File Editor'), [new SyncDescriptor(TestEditorInput)]); - } + disposables.push((Registry.as(EditorExtensions.EditorInputFactories)).registerEditorInputFactory(TEST_EDITOR_INPUT_ID, TestEditorInputFactory)); + disposables.push((Registry.as(Extensions.Editors)).registerEditor(EditorDescriptor.create(TestEditorControl, TEST_EDITOR_ID, 'My Test File Editor'), [new SyncDescriptor(TestEditorInput)])); + }); - registerTestEditorInput(); + teardown(() => { + dispose(disposables); + disposables = []; + }); function createPart(): EditorPart { const instantiationService = workbenchInstantiationService(); diff --git a/src/vs/workbench/services/editor/test/browser/editorService.test.ts b/src/vs/workbench/services/editor/test/browser/editorService.test.ts index 079d1ce5ded..2296fed1da8 100644 --- a/src/vs/workbench/services/editor/test/browser/editorService.test.ts +++ b/src/vs/workbench/services/editor/test/browser/editorService.test.ts @@ -25,7 +25,7 @@ import { EditorServiceImpl } from 'vs/workbench/browser/parts/editor/editor'; import { timeout } from 'vs/base/common/async'; import { toResource } from 'vs/base/test/common/utils'; import { IFileService } from 'vs/platform/files/common/files'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, dispose } from 'vs/base/common/lifecycle'; import { ModesRegistry } from 'vs/editor/common/modes/modesRegistry'; import { UntitledTextEditorModel } from 'vs/workbench/common/editor/untitledTextEditorModel'; import { NullFileSystemProvider } from 'vs/platform/files/test/common/nullFileSystemProvider'; @@ -33,9 +33,12 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { CancellationToken } from 'vscode'; +const TEST_EDITOR_ID = 'MyTestEditorForEditorService'; +const TEST_EDITOR_INPUT_ID = 'testEditorInputForEditorService'; + class TestEditorControl extends BaseEditor { - constructor(@ITelemetryService telemetryService: ITelemetryService) { super('MyTestEditorForEditorService', NullTelemetryService, new TestThemeService(), new TestStorageService()); } + constructor(@ITelemetryService telemetryService: ITelemetryService) { super(TEST_EDITOR_ID, NullTelemetryService, new TestThemeService(), new TestStorageService()); } async setInput(input: EditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { super.setInput(input, options, token); @@ -43,7 +46,7 @@ class TestEditorControl extends BaseEditor { await input.resolve(); } - getId(): string { return 'MyTestEditorForEditorService'; } + getId(): string { return TEST_EDITOR_ID; } layout(): void { } createEditor(): any { } } @@ -57,7 +60,7 @@ class TestEditorInput extends EditorInput implements IFileEditorInput { private fails = false; constructor(public resource: URI) { super(); } - getTypeId() { return 'testEditorInputForEditorService'; } + getTypeId() { return TEST_EDITOR_INPUT_ID; } resolve(): Promise { return !this.fails ? Promise.resolve(null) : Promise.reject(new Error('fails')); } matches(other: TestEditorInput): boolean { return other && other.resource && this.resource.toString() === other.resource.toString() && other instanceof TestEditorInput; } setEncoding(encoding: string) { } @@ -106,11 +109,16 @@ class FileServiceProvider extends Disposable { suite('EditorService', () => { - function registerTestEditorInput(): void { - Registry.as(Extensions.Editors).registerEditor(EditorDescriptor.create(TestEditorControl, 'MyTestEditorForEditorService', 'My Test Editor For Next Editor Service'), [new SyncDescriptor(TestEditorInput)]); - } + let disposables: IDisposable[] = []; - registerTestEditorInput(); + setup(() => { + disposables.push(Registry.as(Extensions.Editors).registerEditor(EditorDescriptor.create(TestEditorControl, TEST_EDITOR_ID, 'My Test Editor For Next Editor Service'), [new SyncDescriptor(TestEditorInput)])); + }); + + teardown(() => { + dispose(disposables); + disposables = []; + }); test('basics', async () => { const partInstantiator = workbenchInstantiationService(); diff --git a/src/vs/workbench/services/history/test/history.test.ts b/src/vs/workbench/services/history/test/history.test.ts index 5a0d860f314..e032184cb5a 100644 --- a/src/vs/workbench/services/history/test/history.test.ts +++ b/src/vs/workbench/services/history/test/history.test.ts @@ -20,14 +20,19 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { EditorsHistory, HistoryService } from 'vs/workbench/services/history/browser/history'; import { WillSaveStateReason } from 'vs/platform/storage/common/storage'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; -import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { EditorService } from 'vs/workbench/services/editor/browser/editorService'; import { timeout } from 'vs/base/common/async'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; +import { IHistoryService } from 'vs/workbench/services/history/common/history'; + +const TEST_EDITOR_ID = 'MyTestEditorForEditorHistory'; +const TEST_EDITOR_INPUT_ID = 'testEditorInputForHistoyService'; +const TEST_SERIALIZABLE_EDITOR_INPUT_ID = 'testSerializableEditorInputForHistoyService'; class TestEditorControl extends BaseEditor { - constructor() { super('MyTestEditorForEditorHistory', NullTelemetryService, new TestThemeService(), new TestStorageService()); } + constructor() { super(TEST_EDITOR_ID, NullTelemetryService, new TestThemeService(), new TestStorageService()); } async setInput(input: EditorInput, options: EditorOptions | undefined, token: CancellationToken): Promise { super.setInput(input, options, token); @@ -35,7 +40,7 @@ class TestEditorControl extends BaseEditor { await input.resolve(); } - getId(): string { return 'MyTestEditorForEditorHistory'; } + getId(): string { return TEST_EDITOR_ID; } layout(): void { } createEditor(): any { } } @@ -44,7 +49,7 @@ class TestEditorInput extends EditorInput implements IFileEditorInput { constructor(public resource: URI) { super(); } - getTypeId() { return 'testEditorInputForEditorGroupService'; } + getTypeId() { return TEST_EDITOR_INPUT_ID; } resolve(): Promise { return Promise.resolve(null); } matches(other: TestEditorInput): boolean { return other && this.resource.toString() === other.resource.toString() && other instanceof TestEditorInput; } setEncoding(encoding: string) { } @@ -57,7 +62,7 @@ class TestEditorInput extends EditorInput implements IFileEditorInput { } class HistoryTestEditorInput extends TestEditorInput { - getTypeId() { return 'testEditorInputForEditorsHistory'; } + getTypeId() { return TEST_SERIALIZABLE_EDITOR_INPUT_ID; } } interface ISerializedTestInput { @@ -95,30 +100,41 @@ async function createServices(): Promise<[EditorPart, HistoryService, EditorServ await part.whenRestored; - const collection = new ServiceCollection(); - collection.set(IEditorGroupsService, part); + instantiationService.stub(IEditorGroupsService, part); - const childInstantiator = instantiationService.createChild(collection); + const editorService = instantiationService.createInstance(EditorService); + instantiationService.stub(IEditorService, editorService); - const childCollection = new ServiceCollection(); - const editorService = childInstantiator.createInstance(EditorService); - collection.set(IEditorService, editorService); - - const historyService = childInstantiator.createChild(childCollection).createInstance(HistoryService); + const historyService = instantiationService.createInstance(HistoryService); + instantiationService.stub(IHistoryService, historyService); return [part, historyService, editorService]; } suite('HistoryService', function () { + let disposables: IDisposable[] = []; + + setup(() => { + disposables.push(Registry.as(EditorExtensions.EditorInputFactories).registerEditorInputFactory(TEST_SERIALIZABLE_EDITOR_INPUT_ID, HistoryTestEditorInputFactory)); + disposables.push(Registry.as(Extensions.Editors).registerEditor(EditorDescriptor.create(TestEditorControl, TEST_EDITOR_ID, 'My Test Editor For History Editor Service'), [new SyncDescriptor(TestEditorInput), new SyncDescriptor(HistoryTestEditorInput)])); + }); + + teardown(() => { + dispose(disposables); + disposables = []; + }); + test('back / forward', async () => { const [part, historyService] = await createServices(); const input1 = new TestEditorInput(URI.parse('foo://bar1')); await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + assert.equal(part.activeGroup.activeEditor, input1); const input2 = new TestEditorInput(URI.parse('foo://bar2')); await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + assert.equal(part.activeGroup.activeEditor, input2); historyService.back(); assert.equal(part.activeGroup.activeEditor, input1); @@ -164,429 +180,418 @@ suite('HistoryService', function () { part.dispose(); }); -}); + suite('EditorHistory', function () { -suite('EditorHistory', function () { + test('basics (single group)', async () => { + const instantiationService = workbenchInstantiationService(); - function registerTestEditorInput(): void { - Registry.as(Extensions.Editors).registerEditor(EditorDescriptor.create(TestEditorControl, 'MyTestEditorForEditorHistory', 'My Test Editor For History Editor Service'), [new SyncDescriptor(TestEditorInput)]); - } + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); - function registerEditorInputFactory() { - Registry.as(EditorExtensions.EditorInputFactories).registerEditorInputFactory('testEditorInputForEditorsHistory', HistoryTestEditorInputFactory); - } + await part.whenRestored; - registerEditorInputFactory(); - registerTestEditorInput(); + const history = new EditorsHistory(part, new TestStorageService()); - test('basics (single group)', async () => { - const instantiationService = workbenchInstantiationService(); + let historyChangeListenerCalled = false; + const listener = history.onDidChange(() => { + historyChangeListenerCalled = true; + }); - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); + let currentHistory = history.editors; + assert.equal(currentHistory.length, 0); + assert.equal(historyChangeListenerCalled, false); - await part.whenRestored; + const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); - const history = new EditorsHistory(part, new TestStorageService()); + await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - let historyChangeListenerCalled = false; - const listener = history.onDidChange(() => { - historyChangeListenerCalled = true; + currentHistory = history.editors; + assert.equal(currentHistory.length, 1); + assert.equal(currentHistory[0].groupId, part.activeGroup.id); + assert.equal(currentHistory[0].editor, input1); + assert.equal(historyChangeListenerCalled, true); + + const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); + const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); + + await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + await part.activeGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + + currentHistory = history.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, part.activeGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, part.activeGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, part.activeGroup.id); + assert.equal(currentHistory[2].editor, input1); + + await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + + currentHistory = history.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, part.activeGroup.id); + assert.equal(currentHistory[0].editor, input2); + assert.equal(currentHistory[1].groupId, part.activeGroup.id); + assert.equal(currentHistory[1].editor, input3); + assert.equal(currentHistory[2].groupId, part.activeGroup.id); + assert.equal(currentHistory[2].editor, input1); + + historyChangeListenerCalled = false; + await part.activeGroup.closeEditor(input1); + + currentHistory = history.editors; + assert.equal(currentHistory.length, 2); + assert.equal(currentHistory[0].groupId, part.activeGroup.id); + assert.equal(currentHistory[0].editor, input2); + assert.equal(currentHistory[1].groupId, part.activeGroup.id); + assert.equal(currentHistory[1].editor, input3); + assert.equal(historyChangeListenerCalled, true); + + await part.activeGroup.closeAllEditors(); + currentHistory = history.editors; + assert.equal(currentHistory.length, 0); + + part.dispose(); + listener.dispose(); }); - let currentHistory = history.editors; - assert.equal(currentHistory.length, 0); - assert.equal(historyChangeListenerCalled, false); + test('basics (multi group)', async () => { + const instantiationService = workbenchInstantiationService(); - const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); - await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + await part.whenRestored; - currentHistory = history.editors; - assert.equal(currentHistory.length, 1); - assert.equal(currentHistory[0].groupId, part.activeGroup.id); - assert.equal(currentHistory[0].editor, input1); - assert.equal(historyChangeListenerCalled, true); + const rootGroup = part.activeGroup; - const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); - const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); + const history = new EditorsHistory(part, new TestStorageService()); - await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - await part.activeGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + let currentHistory = history.editors; + assert.equal(currentHistory.length, 0); - currentHistory = history.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, part.activeGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, part.activeGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, part.activeGroup.id); - assert.equal(currentHistory[2].editor, input1); + const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); - await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); - currentHistory = history.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, part.activeGroup.id); - assert.equal(currentHistory[0].editor, input2); - assert.equal(currentHistory[1].groupId, part.activeGroup.id); - assert.equal(currentHistory[1].editor, input3); - assert.equal(currentHistory[2].groupId, part.activeGroup.id); - assert.equal(currentHistory[2].editor, input1); + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); + await sideGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); - historyChangeListenerCalled = false; - await part.activeGroup.closeEditor(input1); + currentHistory = history.editors; + assert.equal(currentHistory.length, 2); + assert.equal(currentHistory[0].groupId, sideGroup.id); + assert.equal(currentHistory[0].editor, input1); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input1); - currentHistory = history.editors; - assert.equal(currentHistory.length, 2); - assert.equal(currentHistory[0].groupId, part.activeGroup.id); - assert.equal(currentHistory[0].editor, input2); - assert.equal(currentHistory[1].groupId, part.activeGroup.id); - assert.equal(currentHistory[1].editor, input3); - assert.equal(historyChangeListenerCalled, true); + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); - await part.activeGroup.closeAllEditors(); - currentHistory = history.editors; - assert.equal(currentHistory.length, 0); + currentHistory = history.editors; + assert.equal(currentHistory.length, 2); + assert.equal(currentHistory[0].groupId, rootGroup.id); + assert.equal(currentHistory[0].editor, input1); + assert.equal(currentHistory[1].groupId, sideGroup.id); + assert.equal(currentHistory[1].editor, input1); - part.dispose(); - listener.dispose(); - }); + // Opening an editor inactive should not change + // the most recent editor, but rather put it behind + const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); - test('basics (multi group)', async () => { - const instantiationService = workbenchInstantiationService(); + await rootGroup.openEditor(input2, EditorOptions.create({ inactive: true })); - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); + currentHistory = history.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, rootGroup.id); + assert.equal(currentHistory[0].editor, input1); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, sideGroup.id); + assert.equal(currentHistory[2].editor, input1); - await part.whenRestored; + await rootGroup.closeAllEditors(); - const rootGroup = part.activeGroup; + currentHistory = history.editors; + assert.equal(currentHistory.length, 1); + assert.equal(currentHistory[0].groupId, sideGroup.id); + assert.equal(currentHistory[0].editor, input1); - const history = new EditorsHistory(part, new TestStorageService()); - - let currentHistory = history.editors; - assert.equal(currentHistory.length, 0); - - const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); - - const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); + await sideGroup.closeAllEditors(); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); - await sideGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); - - currentHistory = history.editors; - assert.equal(currentHistory.length, 2); - assert.equal(currentHistory[0].groupId, sideGroup.id); - assert.equal(currentHistory[0].editor, input1); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input1); + currentHistory = history.editors; + assert.equal(currentHistory.length, 0); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); + part.dispose(); + }); - currentHistory = history.editors; - assert.equal(currentHistory.length, 2); - assert.equal(currentHistory[0].groupId, rootGroup.id); - assert.equal(currentHistory[0].editor, input1); - assert.equal(currentHistory[1].groupId, sideGroup.id); - assert.equal(currentHistory[1].editor, input1); + test('copy group', async () => { + const instantiationService = workbenchInstantiationService(); - // Opening an editor inactive should not change - // the most recent editor, but rather put it behind - const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); - await rootGroup.openEditor(input2, EditorOptions.create({ inactive: true })); + await part.whenRestored; - currentHistory = history.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, rootGroup.id); - assert.equal(currentHistory[0].editor, input1); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, sideGroup.id); - assert.equal(currentHistory[2].editor, input1); - - await rootGroup.closeAllEditors(); - - currentHistory = history.editors; - assert.equal(currentHistory.length, 1); - assert.equal(currentHistory[0].groupId, sideGroup.id); - assert.equal(currentHistory[0].editor, input1); - - await sideGroup.closeAllEditors(); - - currentHistory = history.editors; - assert.equal(currentHistory.length, 0); - - part.dispose(); - }); - - test('copy group', async () => { - const instantiationService = workbenchInstantiationService(); - - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); - - await part.whenRestored; + const history = new EditorsHistory(part, new TestStorageService()); - const history = new EditorsHistory(part, new TestStorageService()); + const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); + const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); + const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); - const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); - const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); - const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); + const rootGroup = part.activeGroup; - const rootGroup = part.activeGroup; + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input3, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + let currentHistory = history.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, rootGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, rootGroup.id); + assert.equal(currentHistory[2].editor, input1); - let currentHistory = history.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, rootGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, rootGroup.id); - assert.equal(currentHistory[2].editor, input1); + const copiedGroup = part.copyGroup(rootGroup, rootGroup, GroupDirection.RIGHT); + copiedGroup.setActive(true); - const copiedGroup = part.copyGroup(rootGroup, rootGroup, GroupDirection.RIGHT); - copiedGroup.setActive(true); + currentHistory = history.editors; + assert.equal(currentHistory.length, 6); + assert.equal(currentHistory[0].groupId, copiedGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input3); + assert.equal(currentHistory[2].groupId, copiedGroup.id); + assert.equal(currentHistory[2].editor, input2); + assert.equal(currentHistory[3].groupId, copiedGroup.id); + assert.equal(currentHistory[3].editor, input1); + assert.equal(currentHistory[4].groupId, rootGroup.id); + assert.equal(currentHistory[4].editor, input2); + assert.equal(currentHistory[5].groupId, rootGroup.id); + assert.equal(currentHistory[5].editor, input1); + + part.dispose(); + }); + + test('initial editors are part of history and state is persisted & restored (single group)', async () => { + const instantiationService = workbenchInstantiationService(); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorInputFactories).start(accessor)); + + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); + + await part.whenRestored; + + const rootGroup = part.activeGroup; + + const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); + const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); + const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); - currentHistory = history.editors; - assert.equal(currentHistory.length, 6); - assert.equal(currentHistory[0].groupId, copiedGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input3); - assert.equal(currentHistory[2].groupId, copiedGroup.id); - assert.equal(currentHistory[2].editor, input2); - assert.equal(currentHistory[3].groupId, copiedGroup.id); - assert.equal(currentHistory[3].editor, input1); - assert.equal(currentHistory[4].groupId, rootGroup.id); - assert.equal(currentHistory[4].editor, input2); - assert.equal(currentHistory[5].groupId, rootGroup.id); - assert.equal(currentHistory[5].editor, input1); - - part.dispose(); - }); - - test('initial editors are part of history and state is persisted & restored (single group)', async () => { - const instantiationService = workbenchInstantiationService(); - instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorInputFactories).start(accessor)); - - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); - - await part.whenRestored; - - const rootGroup = part.activeGroup; - - const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); - const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); - const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input3, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + const storage = new TestStorageService(); + const history = new EditorsHistory(part, storage); + await part.whenRestored; - const storage = new TestStorageService(); - const history = new EditorsHistory(part, storage); - await part.whenRestored; + let currentHistory = history.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, rootGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, rootGroup.id); + assert.equal(currentHistory[2].editor, input1); - let currentHistory = history.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, rootGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, rootGroup.id); - assert.equal(currentHistory[2].editor, input1); + storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); - storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); + const restoredHistory = new EditorsHistory(part, storage); + await part.whenRestored; - const restoredHistory = new EditorsHistory(part, storage); - await part.whenRestored; + currentHistory = restoredHistory.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, rootGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, rootGroup.id); + assert.equal(currentHistory[2].editor, input1); - currentHistory = restoredHistory.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, rootGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, rootGroup.id); - assert.equal(currentHistory[2].editor, input1); + part.dispose(); + }); - part.dispose(); - }); + test('initial editors are part of history (multi group)', async () => { + const instantiationService = workbenchInstantiationService(); - test('initial editors are part of history (multi group)', async () => { - const instantiationService = workbenchInstantiationService(); + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); + await part.whenRestored; - await part.whenRestored; + const rootGroup = part.activeGroup; - const rootGroup = part.activeGroup; + const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); + const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); + const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); - const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); - const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); - const input3 = new HistoryTestEditorInput(URI.parse('foo://bar3')); + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); + await sideGroup.openEditor(input3, EditorOptions.create({ pinned: true })); - const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); - await sideGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + const storage = new TestStorageService(); + const history = new EditorsHistory(part, storage); + await part.whenRestored; - const storage = new TestStorageService(); - const history = new EditorsHistory(part, storage); - await part.whenRestored; + let currentHistory = history.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, sideGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, rootGroup.id); + assert.equal(currentHistory[2].editor, input1); - let currentHistory = history.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, sideGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, rootGroup.id); - assert.equal(currentHistory[2].editor, input1); + storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); - storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); + const restoredHistory = new EditorsHistory(part, storage); + await part.whenRestored; - const restoredHistory = new EditorsHistory(part, storage); - await part.whenRestored; + currentHistory = restoredHistory.editors; + assert.equal(currentHistory.length, 3); + assert.equal(currentHistory[0].groupId, sideGroup.id); + assert.equal(currentHistory[0].editor, input3); + assert.equal(currentHistory[1].groupId, rootGroup.id); + assert.equal(currentHistory[1].editor, input2); + assert.equal(currentHistory[2].groupId, rootGroup.id); + assert.equal(currentHistory[2].editor, input1); - currentHistory = restoredHistory.editors; - assert.equal(currentHistory.length, 3); - assert.equal(currentHistory[0].groupId, sideGroup.id); - assert.equal(currentHistory[0].editor, input3); - assert.equal(currentHistory[1].groupId, rootGroup.id); - assert.equal(currentHistory[1].editor, input2); - assert.equal(currentHistory[2].groupId, rootGroup.id); - assert.equal(currentHistory[2].editor, input1); + part.dispose(); + }); - part.dispose(); - }); + test('history does not restore editors that cannot be serialized', async () => { + const instantiationService = workbenchInstantiationService(); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorInputFactories).start(accessor)); - test('history does not restore editors that cannot be serialized', async () => { - const instantiationService = workbenchInstantiationService(); - instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorInputFactories).start(accessor)); + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); + await part.whenRestored; - await part.whenRestored; + const rootGroup = part.activeGroup; - const rootGroup = part.activeGroup; + const input1 = new TestEditorInput(URI.parse('foo://bar1')); - const input1 = new TestEditorInput(URI.parse('foo://bar1')); + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + const storage = new TestStorageService(); + const history = new EditorsHistory(part, storage); + await part.whenRestored; - const storage = new TestStorageService(); - const history = new EditorsHistory(part, storage); - await part.whenRestored; + let currentHistory = history.editors; + assert.equal(currentHistory.length, 1); + assert.equal(currentHistory[0].groupId, rootGroup.id); + assert.equal(currentHistory[0].editor, input1); - let currentHistory = history.editors; - assert.equal(currentHistory.length, 1); - assert.equal(currentHistory[0].groupId, rootGroup.id); - assert.equal(currentHistory[0].editor, input1); + storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); - storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); + const restoredHistory = new EditorsHistory(part, storage); + await part.whenRestored; - const restoredHistory = new EditorsHistory(part, storage); - await part.whenRestored; + currentHistory = restoredHistory.editors; + assert.equal(currentHistory.length, 0); - currentHistory = restoredHistory.editors; - assert.equal(currentHistory.length, 0); + part.dispose(); + }); - part.dispose(); - }); + test('open next/previous recently used editor (single group)', async () => { + const [part, historyService] = await createServices(); - test('open next/previous recently used editor (single group)', async () => { - const [part, historyService] = await createServices(); + const input1 = new TestEditorInput(URI.parse('foo://bar1')); + const input2 = new TestEditorInput(URI.parse('foo://bar2')); - const input1 = new TestEditorInput(URI.parse('foo://bar1')); - const input2 = new TestEditorInput(URI.parse('foo://bar2')); + await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + assert.equal(part.activeGroup.activeEditor, input1); - await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - assert.equal(part.activeGroup.activeEditor, input1); + await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + assert.equal(part.activeGroup.activeEditor, input2); - await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - assert.equal(part.activeGroup.activeEditor, input2); + historyService.openPreviouslyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input1); - historyService.openPreviouslyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input1); + historyService.openNextRecentlyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input2); - historyService.openNextRecentlyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input2); + historyService.openPreviouslyUsedEditor(part.activeGroup.id); + assert.equal(part.activeGroup.activeEditor, input1); - historyService.openPreviouslyUsedEditor(part.activeGroup.id); - assert.equal(part.activeGroup.activeEditor, input1); + historyService.openNextRecentlyUsedEditor(part.activeGroup.id); + assert.equal(part.activeGroup.activeEditor, input2); - historyService.openNextRecentlyUsedEditor(part.activeGroup.id); - assert.equal(part.activeGroup.activeEditor, input2); + part.dispose(); + }); - part.dispose(); - }); + test('open next/previous recently used editor (multi group)', async () => { + const [part, historyService] = await createServices(); + const rootGroup = part.activeGroup; - test('open next/previous recently used editor (multi group)', async () => { - const [part, historyService] = await createServices(); - const rootGroup = part.activeGroup; + const input1 = new TestEditorInput(URI.parse('foo://bar1')); + const input2 = new TestEditorInput(URI.parse('foo://bar2')); - const input1 = new TestEditorInput(URI.parse('foo://bar1')); - const input2 = new TestEditorInput(URI.parse('foo://bar2')); + const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); - const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + await sideGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - await sideGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + historyService.openPreviouslyUsedEditor(); + assert.equal(part.activeGroup, rootGroup); + assert.equal(rootGroup.activeEditor, input1); - historyService.openPreviouslyUsedEditor(); - assert.equal(part.activeGroup, rootGroup); - assert.equal(rootGroup.activeEditor, input1); + historyService.openNextRecentlyUsedEditor(); + assert.equal(part.activeGroup, sideGroup); + assert.equal(sideGroup.activeEditor, input2); - historyService.openNextRecentlyUsedEditor(); - assert.equal(part.activeGroup, sideGroup); - assert.equal(sideGroup.activeEditor, input2); + part.dispose(); + }); - part.dispose(); - }); + test('open next/previous recently is reset when other input opens', async () => { + const [part, historyService] = await createServices(); - test('open next/previous recently is reset when other input opens', async () => { - const [part, historyService] = await createServices(); + const input1 = new TestEditorInput(URI.parse('foo://bar1')); + const input2 = new TestEditorInput(URI.parse('foo://bar2')); + const input3 = new TestEditorInput(URI.parse('foo://bar3')); + const input4 = new TestEditorInput(URI.parse('foo://bar4')); - const input1 = new TestEditorInput(URI.parse('foo://bar1')); - const input2 = new TestEditorInput(URI.parse('foo://bar2')); - const input3 = new TestEditorInput(URI.parse('foo://bar3')); - const input4 = new TestEditorInput(URI.parse('foo://bar4')); + await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); + await part.activeGroup.openEditor(input3, EditorOptions.create({ pinned: true })); - await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - await part.activeGroup.openEditor(input2, EditorOptions.create({ pinned: true })); - await part.activeGroup.openEditor(input3, EditorOptions.create({ pinned: true })); + historyService.openPreviouslyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input2); - historyService.openPreviouslyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input2); + await timeout(0); + await part.activeGroup.openEditor(input4, EditorOptions.create({ pinned: true })); - await timeout(0); - await part.activeGroup.openEditor(input4, EditorOptions.create({ pinned: true })); + historyService.openPreviouslyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input2); - historyService.openPreviouslyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input2); + historyService.openNextRecentlyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input4); - historyService.openNextRecentlyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input4); - - part.dispose(); + part.dispose(); + }); }); }); + diff --git a/src/vs/workbench/services/textfile/browser/textFileService.ts b/src/vs/workbench/services/textfile/browser/textFileService.ts index 3a00df0a6e7..5f4ec3ee4b8 100644 --- a/src/vs/workbench/services/textfile/browser/textFileService.ts +++ b/src/vs/workbench/services/textfile/browser/textFileService.ts @@ -433,12 +433,7 @@ export abstract class AbstractTextFileService extends Disposable implements ITex modelToRestoreResource = joinPath(target, sourceModelResource.path.substr(source.path.length + 1)); } - let mode: string | undefined = sourceModel.textEditorModel?.getModeId(); - if (mode === PLAINTEXT_MODE_ID) { - mode = undefined; // never enforce plain text mode when moving as it is unspecific - } - - const modelToRestore: ModelToRestore = { resource: modelToRestoreResource, encoding: sourceModel.getEncoding(), mode }; + const modelToRestore: ModelToRestore = { resource: modelToRestoreResource, encoding: sourceModel.getEncoding() }; if (sourceModel.isDirty()) { modelToRestore.snapshot = sourceModel.createSnapshot(); } @@ -771,9 +766,13 @@ export abstract class AbstractTextFileService extends Disposable implements ITex await this.create(target, ''); } - let mode: string | undefined = sourceModel.textEditorModel?.getModeId(); - if (mode === PLAINTEXT_MODE_ID) { - mode = undefined; // never enforce plain text mode when moving as it is unspecific + // Carry over the mode if this is an untitled file and the mode was picked by the user + let mode: string | undefined; + if (sourceModel instanceof UntitledTextEditorModel) { + mode = sourceModel.getMode(); + if (mode === PLAINTEXT_MODE_ID) { + mode = undefined; // never enforce plain text mode when moving as it is unspecific + } } targetModel = await this.models.loadOrCreate(target, { encoding: sourceModel.getEncoding(), mode }); diff --git a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts index fb8822c7f3c..13c8397c189 100644 --- a/src/vs/workbench/services/textfile/common/textFileEditorModel.ts +++ b/src/vs/workbench/services/textfile/common/textFileEditorModel.ts @@ -960,6 +960,14 @@ export class TextFileEditorModel extends BaseTextEditorModel implements ITextFil } } + getMode(): string | undefined { + if (this.textEditorModel) { + return this.textEditorModel.getModeId(); + } + + return this.preferredMode; + } + getEncoding(): string | undefined { return this.preferredEncoding || this.contentEncoding; } diff --git a/src/vs/workbench/services/textfile/common/textfiles.ts b/src/vs/workbench/services/textfile/common/textfiles.ts index 8b6750df496..b7abec08cba 100644 --- a/src/vs/workbench/services/textfile/common/textfiles.ts +++ b/src/vs/workbench/services/textfile/common/textfiles.ts @@ -456,6 +456,8 @@ export interface ITextFileEditorModel extends ITextEditorModel, IEncodingSupport makeDirty(): void; + getMode(): string | undefined; + isResolved(): this is IResolvedTextFileEditorModel; isDisposed(): boolean; @@ -468,9 +470,6 @@ export interface IResolvedTextFileEditorModel extends ITextFileEditorModel { createSnapshot(): ITextSnapshot; } -/** - * Helper method to convert a snapshot into its full string form. - */ export function snapshotToString(snapshot: ITextSnapshot): string { const chunks: string[] = []; diff --git a/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts b/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts index bd26f256b6d..b6abedc45d5 100644 --- a/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts +++ b/src/vs/workbench/test/browser/parts/editor/baseEditor.test.ts @@ -6,7 +6,6 @@ import * as assert from 'assert'; import { BaseEditor, EditorMemento } from 'vs/workbench/browser/parts/editor/baseEditor'; import { EditorInput, EditorOptions, IEditorInputFactory, IEditorInputFactoryRegistry, Extensions as EditorExtensions } from 'vs/workbench/common/editor'; -import { TestInstantiationService } from 'vs/platform/instantiation/test/common/instantiationServiceMock'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import * as Platform from 'vs/platform/registry/common/platform'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; @@ -19,6 +18,7 @@ import { URI } from 'vs/base/common/uri'; import { IEditorRegistry, Extensions, EditorDescriptor } from 'vs/workbench/browser/editor'; import { CancellationToken } from 'vs/base/common/cancellation'; import { IEditorModel } from 'vs/platform/editor/common/editor'; +import { dispose } from 'vs/base/common/lifecycle'; const NullThemeService = new TestThemeService(); @@ -131,8 +131,8 @@ suite('Workbench base editor', () => { let oldEditorsCnt = EditorRegistry.getEditors().length; let oldInputCnt = (EditorRegistry).getEditorInputs().length; - EditorRegistry.registerEditor(d1, [new SyncDescriptor(MyInput)]); - EditorRegistry.registerEditor(d2, [new SyncDescriptor(MyInput), new SyncDescriptor(MyOtherInput)]); + const dispose1 = EditorRegistry.registerEditor(d1, [new SyncDescriptor(MyInput)]); + const dispose2 = EditorRegistry.registerEditor(d2, [new SyncDescriptor(MyInput), new SyncDescriptor(MyOtherInput)]); assert.equal(EditorRegistry.getEditors().length, oldEditorsCnt + 2); assert.equal((EditorRegistry).getEditorInputs().length, oldInputCnt + 3); @@ -143,51 +143,41 @@ suite('Workbench base editor', () => { assert.strictEqual(EditorRegistry.getEditorById('id1'), d1); assert.strictEqual(EditorRegistry.getEditorById('id2'), d2); assert(!EditorRegistry.getEditorById('id3')); + + dispose([dispose1, dispose2]); }); test('Editor Lookup favors specific class over superclass (match on specific class)', function () { let d1 = EditorDescriptor.create(MyEditor, 'id1', 'name'); - let d2 = EditorDescriptor.create(MyOtherEditor, 'id2', 'name'); - let oldEditors = EditorRegistry.getEditors(); - (EditorRegistry).setEditors([]); + const disposable = EditorRegistry.registerEditor(d1, [new SyncDescriptor(MyResourceInput)]); - EditorRegistry.registerEditor(d2, [new SyncDescriptor(ResourceEditorInput)]); - EditorRegistry.registerEditor(d1, [new SyncDescriptor(MyResourceInput)]); - - let inst = new TestInstantiationService(); + let inst = workbenchInstantiationService(); const editor = EditorRegistry.getEditor(inst.createInstance(MyResourceInput, 'fake', '', URI.file('/fake'), undefined))!.instantiate(inst); assert.strictEqual(editor.getId(), 'myEditor'); const otherEditor = EditorRegistry.getEditor(inst.createInstance(ResourceEditorInput, 'fake', '', URI.file('/fake'), undefined))!.instantiate(inst); - assert.strictEqual(otherEditor.getId(), 'myOtherEditor'); + assert.strictEqual(otherEditor.getId(), 'workbench.editors.textResourceEditor'); - (EditorRegistry).setEditors(oldEditors); + disposable.dispose(); }); test('Editor Lookup favors specific class over superclass (match on super class)', function () { - let d1 = EditorDescriptor.create(MyOtherEditor, 'id1', 'name'); - - let oldEditors = EditorRegistry.getEditors(); - (EditorRegistry).setEditors([]); - - EditorRegistry.registerEditor(d1, [new SyncDescriptor(ResourceEditorInput)]); - - let inst = new TestInstantiationService(); + let inst = workbenchInstantiationService(); const editor = EditorRegistry.getEditor(inst.createInstance(MyResourceInput, 'fake', '', URI.file('/fake'), undefined))!.instantiate(inst); - assert.strictEqual('myOtherEditor', editor.getId()); - - (EditorRegistry).setEditors(oldEditors); + assert.strictEqual('workbench.editors.textResourceEditor', editor.getId()); }); test('Editor Input Factory', function () { workbenchInstantiationService().invokeFunction(accessor => EditorInputRegistry.start(accessor)); - EditorInputRegistry.registerEditorInputFactory('myInputId', MyInputFactory); + const disposable = EditorInputRegistry.registerEditorInputFactory('myInputId', MyInputFactory); let factory = EditorInputRegistry.getEditorInputFactory('myInputId'); assert(factory); + + disposable.dispose(); }); test('EditorMemento - basics', function () { diff --git a/src/vs/workbench/test/common/editor/editorGroups.test.ts b/src/vs/workbench/test/common/editor/editorGroups.test.ts index 9aa743a2b34..990b1fadbe3 100644 --- a/src/vs/workbench/test/common/editor/editorGroups.test.ts +++ b/src/vs/workbench/test/common/editor/editorGroups.test.ts @@ -20,6 +20,7 @@ import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { DiffEditorInput } from 'vs/workbench/common/editor/diffEditorInput'; import { IStorageService } from 'vs/platform/storage/common/storage'; +import { IDisposable, dispose } from 'vs/base/common/lifecycle'; function inst(): IInstantiationService { let inst = new TestInstantiationService(); @@ -160,13 +161,16 @@ class TestEditorInputFactory implements IEditorInputFactory { suite('Workbench editor groups', () => { - function registerEditorInputFactory() { - Registry.as(EditorExtensions.EditorInputFactories).registerEditorInputFactory('testEditorInputForGroups', TestEditorInputFactory); - } + let disposables: IDisposable[] = []; - registerEditorInputFactory(); + setup(() => { + disposables.push(Registry.as(EditorExtensions.EditorInputFactories).registerEditorInputFactory('testEditorInputForGroups', TestEditorInputFactory)); + }); teardown(() => { + dispose(disposables); + disposables = []; + index = 1; }); diff --git a/src/vs/workbench/test/electron-browser/api/extHostTreeViews.test.ts b/src/vs/workbench/test/electron-browser/api/extHostTreeViews.test.ts index bc50a3dd00a..fb419d8e700 100644 --- a/src/vs/workbench/test/electron-browser/api/extHostTreeViews.test.ts +++ b/src/vs/workbench/test/electron-browser/api/extHostTreeViews.test.ts @@ -75,7 +75,7 @@ suite('ExtHostTreeView', function () { rpcProtocol, new NullLogService() ), new NullLogService()); - onDidChangeTreeNode = new Emitter<{ key: string }>(); + onDidChangeTreeNode = new Emitter<{ key: string } | undefined>(); onDidChangeTreeNodeWithId = new Emitter<{ key: string }>(); testObject.createTreeView('testNodeTreeProvider', { treeDataProvider: aNodeTreeDataProvider() }, { enableProposedApi: true } as IExtensionDescription); testObject.createTreeView('testNodeWithIdTreeProvider', { treeDataProvider: aNodeWithIdTreeDataProvider() }, { enableProposedApi: true } as IExtensionDescription); diff --git a/src/vs/workbench/test/workbenchTestServices.ts b/src/vs/workbench/test/workbenchTestServices.ts index 1e96afa4b1d..b088fe4928a 100644 --- a/src/vs/workbench/test/workbenchTestServices.ts +++ b/src/vs/workbench/test/workbenchTestServices.ts @@ -33,7 +33,7 @@ import { ITextFileStreamContent, ITextFileService, IResourceEncoding, IReadTextF import { parseArgs, OPTIONS } from 'vs/platform/environment/node/argv'; import { IModeService } from 'vs/editor/common/services/modeService'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; -import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { IInstantiationService, ServicesAccessor, ServiceIdentifier } from 'vs/platform/instantiation/common/instantiation'; import { TestConfigurationService } from 'vs/platform/configuration/test/common/testConfigurationService'; import { MenuBarVisibility, IWindowConfiguration, IWindowOpenable, IOpenWindowOptions, IOpenEmptyWindowOptions, IOpenedWindow } from 'vs/platform/windows/common/windows'; import { TestWorkspace } from 'vs/platform/workspace/test/common/testWorkspace'; @@ -94,6 +94,7 @@ import { IDialogMainService } from 'vs/platform/dialogs/electron-main/dialogs'; import { find } from 'vs/base/common/arrays'; import { WorkingCopyService, IWorkingCopyService } from 'vs/workbench/services/workingCopy/common/workingCopyService'; import { IFilesConfigurationService, FilesConfigurationService } from 'vs/workbench/services/filesConfiguration/common/filesConfigurationService'; +import { IAccessibilityService, AccessibilitySupport } from 'vs/platform/accessibility/common/accessibility'; export function createFileInput(instantiationService: IInstantiationService, resource: URI): FileEditorInput { return instantiationService.createInstance(FileEditorInput, resource, undefined, undefined); @@ -276,7 +277,11 @@ export class TestTextFileService extends NativeTextFileService { } } -export function workbenchInstantiationService(): IInstantiationService { +export interface ITestInstantiationService extends IInstantiationService { + stub(service: ServiceIdentifier, ctor: any): T; +} + +export function workbenchInstantiationService(): ITestInstantiationService { let instantiationService = new TestInstantiationService(new ServiceCollection([ILifecycleService, new TestLifecycleService()])); instantiationService.stub(IEnvironmentService, TestEnvironmentService); const contextKeyService = instantiationService.createInstance(MockContextKeyService); @@ -291,6 +296,7 @@ export function workbenchInstantiationService(): IInstantiationService { instantiationService.stub(IStorageService, new TestStorageService()); instantiationService.stub(IWorkbenchLayoutService, new TestLayoutService()); instantiationService.stub(IDialogService, new TestDialogService()); + instantiationService.stub(IAccessibilityService, new TestAccessibilityService()); instantiationService.stub(IFileDialogService, new TestFileDialogService()); instantiationService.stub(IElectronService, new TestElectronService()); instantiationService.stub(IModeService, instantiationService.createInstance(ModeServiceImpl)); @@ -322,6 +328,17 @@ export function workbenchInstantiationService(): IInstantiationService { return instantiationService; } +export class TestAccessibilityService implements IAccessibilityService { + + _serviceBrand: undefined; + + onDidChangeAccessibilitySupport = Event.None; + + alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } + getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; } + setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { } +} + export class TestDecorationsService implements IDecorationsService { _serviceBrand: undefined; onDidChangeDecorations: Event = Event.None;