From d92afa067c3b7dd2d8242b1fc15d1a588b7e98ba Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 19 Dec 2019 12:21:08 +0100 Subject: [PATCH] editors - introduce and use EditorsObserver and use for MRU list --- .../workbench/browser/parts/editor/editor.ts | 10 + .../browser/parts/editor/editorsObserver.ts | 296 ++++++++++ .../services/editor/browser/editorService.ts | 13 +- .../test/browser/editorsObserver.test.ts | 538 ++++++++++++++++++ .../services/history/browser/history.ts | 318 +---------- .../services/history/common/history.ts | 4 +- .../services/history/test/history.test.ts | 420 +------------- .../workbench/test/workbenchTestServices.ts | 4 +- 8 files changed, 876 insertions(+), 727 deletions(-) create mode 100644 src/vs/workbench/browser/parts/editor/editorsObserver.ts create mode 100644 src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts diff --git a/src/vs/workbench/browser/parts/editor/editor.ts b/src/vs/workbench/browser/parts/editor/editor.ts index bf44520a5dd..7434fe1b1cf 100644 --- a/src/vs/workbench/browser/parts/editor/editor.ts +++ b/src/vs/workbench/browser/parts/editor/editor.ts @@ -151,4 +151,14 @@ export interface EditorServiceImpl extends IEditorService { * Emitted when an editor failed to open. */ readonly onDidOpenEditorFail: Event; + + /** + * Emitted when the list of most recently active editors change. + */ + readonly onDidMostRecentlyActiveEditorsChange: Event; + + /** + * Access to the list of most recently active editors. + */ + readonly mostRecentlyActiveEditors: ReadonlyArray; } diff --git a/src/vs/workbench/browser/parts/editor/editorsObserver.ts b/src/vs/workbench/browser/parts/editor/editorsObserver.ts new file mode 100644 index 00000000000..9c6f9ce6d10 --- /dev/null +++ b/src/vs/workbench/browser/parts/editor/editorsObserver.ts @@ -0,0 +1,296 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IEditorInput, IEditorInputFactoryRegistry, IEditorIdentifier, GroupIdentifier, Extensions } from 'vs/workbench/common/editor'; +import { dispose, Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { Event, Emitter } from 'vs/base/common/event'; +import { IEditorGroupsService, IEditorGroup, EditorsOrder, GroupChangeKind, GroupsOrder } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { coalesce } from 'vs/base/common/arrays'; +import { LinkedMap, Touch } from 'vs/base/common/map'; + +interface ISerializedEditorsList { + entries: ISerializedEditorIdentifier[]; +} + +interface ISerializedEditorIdentifier { + groupId: GroupIdentifier; + index: number; +} + +/** + * A observer of opened editors across all editor groups by most recently used. + * Rules: + * - the last editor in the list is the one most recently activated + * - the first editor in the list is the one that was activated the longest time ago + * - an editor that opens inactive will be placed behind the currently active editor + */ +export class EditorsObserver extends Disposable { + + private static readonly STORAGE_KEY = 'editorsObserver.state'; + + private readonly keyMap = new Map>(); + private readonly mostRecentEditorsMap = new LinkedMap(); + + private readonly _onDidChange = this._register(new Emitter()); + readonly onDidChange = this._onDidChange.event; + + get editors(): IEditorIdentifier[] { + return this.mostRecentEditorsMap.values(); + } + + constructor( + @IEditorGroupsService private editorGroupsService: IEditorGroupsService, + @IStorageService private readonly storageService: IStorageService + ) { + super(); + + this.registerListeners(); + } + + private registerListeners(): void { + this._register(this.storageService.onWillSaveState(() => this.saveState())); + this._register(this.editorGroupsService.onDidAddGroup(group => this.onGroupAdded(group))); + + this.editorGroupsService.whenRestored.then(() => this.loadState()); + } + + private onGroupAdded(group: IEditorGroup): void { + + // Make sure to add any already existing editor + // of the new group into our list in LRU order + const groupEditorsMru = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); + for (let i = groupEditorsMru.length - 1; i >= 0; i--) { + this.addMostRecentEditor(group, groupEditorsMru[i], false /* is not active */); + } + + // Make sure that active editor is put as first if group is active + if (this.editorGroupsService.activeGroup === group && group.activeEditor) { + this.addMostRecentEditor(group, group.activeEditor, true /* is active */); + } + + // Group Listeners + this.registerGroupListeners(group); + } + + private registerGroupListeners(group: IEditorGroup): void { + const groupDisposables = new DisposableStore(); + groupDisposables.add(group.onDidGroupChange(e => { + switch (e.kind) { + + // Group gets active: put active editor as most recent + case GroupChangeKind.GROUP_ACTIVE: { + if (this.editorGroupsService.activeGroup === group && group.activeEditor) { + this.addMostRecentEditor(group, group.activeEditor, true /* is active */); + } + + break; + } + + // Editor gets active: put active editor as most recent + // if group is active, otherwise second most recent + case GroupChangeKind.EDITOR_ACTIVE: { + if (e.editor) { + this.addMostRecentEditor(group, e.editor, this.editorGroupsService.activeGroup === group); + } + + break; + } + + // Editor opens: put it as second most recent + case GroupChangeKind.EDITOR_OPEN: { + if (e.editor) { + this.addMostRecentEditor(group, e.editor, false /* is not active */); + } + + break; + } + + // Editor closes: remove from recently opened + case GroupChangeKind.EDITOR_CLOSE: { + if (e.editor) { + this.removeMostRecentEditor(group, e.editor); + } + + break; + } + } + })); + + // Make sure to cleanup on dispose + Event.once(group.onWillDispose)(() => dispose(groupDisposables)); + } + + private addMostRecentEditor(group: IEditorGroup, editor: IEditorInput, isActive: boolean): void { + const key = this.ensureKey(group, editor); + const mostRecentEditor = this.mostRecentEditorsMap.first; + + // Active or first entry: add to end of map + if (isActive || !mostRecentEditor) { + this.mostRecentEditorsMap.set(key, key, mostRecentEditor ? Touch.AsOld /* make first */ : undefined); + } + + // Otherwise: insert before most recent + else { + // we have most recent editors. as such we + // put this newly opened editor right before + // the current most recent one because it cannot + // be the most recently active one unless + // it becomes active. but it is still more + // active then any other editor in the list. + this.mostRecentEditorsMap.set(key, key, Touch.AsOld /* make first */); + this.mostRecentEditorsMap.set(mostRecentEditor, mostRecentEditor, Touch.AsOld /* make first */); + } + + // Event + this._onDidChange.fire(); + } + + private removeMostRecentEditor(group: IEditorGroup, editor: IEditorInput): void { + const key = this.findKey(group, editor); + if (key) { + + // Remove from most recent editors + this.mostRecentEditorsMap.delete(key); + + // Remove from key map + const map = this.keyMap.get(group.id); + if (map && map.delete(key.editor) && map.size === 0) { + this.keyMap.delete(group.id); + } + + // Event + this._onDidChange.fire(); + } + } + + private findKey(group: IEditorGroup, editor: IEditorInput): IEditorIdentifier | undefined { + const groupMap = this.keyMap.get(group.id); + if (!groupMap) { + return undefined; + } + + return groupMap.get(editor); + } + + private ensureKey(group: IEditorGroup, editor: IEditorInput): IEditorIdentifier { + let groupMap = this.keyMap.get(group.id); + if (!groupMap) { + groupMap = new Map(); + + this.keyMap.set(group.id, groupMap); + } + + let key = groupMap.get(editor); + if (!key) { + key = { groupId: group.id, editor }; + groupMap.set(editor, key); + } + + return key; + } + + private saveState(): void { + if (this.mostRecentEditorsMap.isEmpty()) { + this.storageService.remove(EditorsObserver.STORAGE_KEY, StorageScope.WORKSPACE); + } else { + this.storageService.store(EditorsObserver.STORAGE_KEY, JSON.stringify(this.serialize()), StorageScope.WORKSPACE); + } + } + + private serialize(): ISerializedEditorsList { + const registry = Registry.as(Extensions.EditorInputFactories); + + const entries = this.mostRecentEditorsMap.values(); + const mapGroupToSerializableEditorsOfGroup = new Map(); + + return { + entries: coalesce(entries.map(({ editor, groupId }) => { + + // Find group for entry + const group = this.editorGroupsService.getGroup(groupId); + if (!group) { + return undefined; + } + + // Find serializable editors of group + let serializableEditorsOfGroup = mapGroupToSerializableEditorsOfGroup.get(group); + if (!serializableEditorsOfGroup) { + serializableEditorsOfGroup = group.getEditors(EditorsOrder.SEQUENTIAL).filter(editor => { + const factory = registry.getEditorInputFactory(editor.getTypeId()); + + return factory?.canSerialize(editor); + }); + mapGroupToSerializableEditorsOfGroup.set(group, serializableEditorsOfGroup); + } + + // Only store the index of the editor of that group + // which can be undefined if the editor is not serializable + const index = serializableEditorsOfGroup.indexOf(editor); + if (index === -1) { + return undefined; + } + + return { groupId, index }; + })) + }; + } + + private loadState(): void { + const serialized = this.storageService.get(EditorsObserver.STORAGE_KEY, StorageScope.WORKSPACE); + + // Previous state: + if (serialized) { + + // Load editors map from persisted state + this.deserialize(JSON.parse(serialized)); + } + + // No previous state: best we can do is add each editor + // from oldest to most recently used editor group + else { + const groups = this.editorGroupsService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); + for (let i = groups.length - 1; i >= 0; i--) { + const group = groups[i]; + const groupEditorsMru = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); + for (let i = groupEditorsMru.length - 1; i >= 0; i--) { + this.addMostRecentEditor(group, groupEditorsMru[i], true /* enforce as active to preserve order */); + } + } + } + + // Ensure we listen on group changes for those that exist on startup + for (const group of this.editorGroupsService.groups) { + this.registerGroupListeners(group); + } + } + + private deserialize(serialized: ISerializedEditorsList): void { + const mapValues: [IEditorIdentifier, IEditorIdentifier][] = []; + + for (const { groupId, index } of serialized.entries) { + + // Find group for entry + const group = this.editorGroupsService.getGroup(groupId); + if (!group) { + continue; + } + + // Find editor for entry + const editor = group.getEditorByIndex(index); + if (!editor) { + continue; + } + + // Make sure key is registered as well + const editorIdentifier = this.ensureKey(group, editor); + mapValues.push([editorIdentifier, editorIdentifier]); + } + + // Fill map with deserialized values + this.mostRecentEditorsMap.fromJSON(mapValues); + } +} diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 987883a3fb4..a50defb71e3 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -27,6 +27,7 @@ import { IEditorGroupView, IEditorOpeningEvent, EditorServiceImpl } from 'vs/wor import { ILabelService } from 'vs/platform/label/common/label'; import { registerSingleton } from 'vs/platform/instantiation/common/extensions'; import { withNullAsUndefined } from 'vs/base/common/types'; +import { EditorsObserver } from 'vs/workbench/browser/parts/editor/editorsObserver'; type CachedEditorInput = ResourceEditorInput | IFileEditorInput; type OpenInEditorGroup = IEditorGroup | GroupIdentifier | SIDE_GROUP_TYPE | ACTIVE_GROUP_TYPE; @@ -51,14 +52,19 @@ export class EditorService extends Disposable implements EditorServiceImpl { private readonly _onDidOpenEditorFail = this._register(new Emitter()); readonly onDidOpenEditorFail = this._onDidOpenEditorFail.event; + private readonly _onDidMostRecentlyActiveEditorsChange = this._register(new Emitter()); + readonly onDidMostRecentlyActiveEditorsChange = this._onDidMostRecentlyActiveEditorsChange.event; + //#endregion private fileInputFactory: IFileInputFactory; - private openEditorHandlers: IOpenEditorOverrideHandler[] = []; + private readonly openEditorHandlers: IOpenEditorOverrideHandler[] = []; private lastActiveEditor: IEditorInput | undefined = undefined; private lastActiveGroupId: GroupIdentifier | undefined = undefined; + private readonly editorsObserver = this._register(this.instantiationService.createInstance(EditorsObserver)); + constructor( @IEditorGroupsService private readonly editorGroupService: IEditorGroupsService, @IUntitledTextEditorService private readonly untitledTextEditorService: IUntitledTextEditorService, @@ -80,6 +86,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { this.editorGroupService.whenRestored.then(() => this.onEditorsRestored()); this.editorGroupService.onDidActiveGroupChange(group => this.handleActiveEditorChange(group)); this.editorGroupService.onDidAddGroup(group => this.registerGroupListeners(group as IEditorGroupView)); + this.editorsObserver.onDidChange(() => this._onDidMostRecentlyActiveEditorsChange.fire()); } private onEditorsRestored(): void { @@ -188,6 +195,10 @@ export class EditorService extends Disposable implements EditorServiceImpl { return editors; } + get mostRecentlyActiveEditors(): IEditorIdentifier[] { + return this.editorsObserver.editors; + } + get activeEditor(): IEditorInput | undefined { const activeGroup = this.editorGroupService.activeGroup; diff --git a/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts b/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts new file mode 100644 index 00000000000..f61b53c29e5 --- /dev/null +++ b/src/vs/workbench/services/editor/test/browser/editorsObserver.test.ts @@ -0,0 +1,538 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { EditorOptions, EditorInput, IEditorInputFactoryRegistry, Extensions as EditorExtensions, IEditorInputFactory, IFileEditorInput } from 'vs/workbench/common/editor'; +import { URI } from 'vs/base/common/uri'; +import { workbenchInstantiationService, TestStorageService } from 'vs/workbench/test/workbenchTestServices'; +import { Registry } from 'vs/platform/registry/common/platform'; +import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart'; +import { IEditorRegistry, EditorDescriptor, Extensions } from 'vs/workbench/browser/editor'; +import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; +import { GroupDirection, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { EditorActivation, IEditorModel } from 'vs/platform/editor/common/editor'; +import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; +import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; +import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; +import { CancellationToken } from 'vs/base/common/cancellation'; +import { 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 { 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'; +import { EditorsObserver } from 'vs/workbench/browser/parts/editor/editorsObserver'; + +const TEST_EDITOR_ID = 'MyTestEditorForEditorsObserver'; +const TEST_EDITOR_INPUT_ID = 'testEditorInputForEditorsObserver'; +const TEST_SERIALIZABLE_EDITOR_INPUT_ID = 'testSerializableEditorInputForEditorsObserver'; + +class TestEditorControl extends BaseEditor { + + 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); + + await input.resolve(); + } + + getId(): string { return TEST_EDITOR_ID; } + layout(): void { } + createEditor(): any { } +} + +class TestEditorInput extends EditorInput implements IFileEditorInput { + + constructor(public resource: URI) { super(); } + + 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) { } + getEncoding() { return undefined; } + setPreferredEncoding(encoding: string) { } + setMode(mode: string) { } + setPreferredMode(mode: string) { } + getResource(): URI { return this.resource; } + setForceOpenAsBinary(): void { } +} + +class EditorsObserverTestEditorInput extends TestEditorInput { + getTypeId() { return TEST_SERIALIZABLE_EDITOR_INPUT_ID; } +} + +interface ISerializedTestInput { + resource: string; +} + +class EditorsObserverTestEditorInputFactory implements IEditorInputFactory { + + canSerialize(editorInput: EditorInput): boolean { + return true; + } + + serialize(editorInput: EditorInput): string { + let testEditorInput = editorInput; + let testInput: ISerializedTestInput = { + resource: testEditorInput.resource.toString() + }; + + return JSON.stringify(testInput); + } + + deserialize(instantiationService: IInstantiationService, serializedEditorInput: string): EditorInput { + let testInput: ISerializedTestInput = JSON.parse(serializedEditorInput); + + return new EditorsObserverTestEditorInput(URI.parse(testInput.resource)); + } +} + +async function createServices(): Promise<[EditorPart, HistoryService, EditorService]> { + const instantiationService = workbenchInstantiationService(); + + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); + + await part.whenRestored; + + instantiationService.stub(IEditorGroupsService, part); + + const editorService = instantiationService.createInstance(EditorService); + instantiationService.stub(IEditorService, editorService); + + const historyService = instantiationService.createInstance(HistoryService); + instantiationService.stub(IHistoryService, historyService); + + return [part, historyService, editorService]; +} + +suite('EditorsObserver', function () { + + let disposables: IDisposable[] = []; + + setup(() => { + disposables.push(Registry.as(EditorExtensions.EditorInputFactories).registerEditorInputFactory(TEST_SERIALIZABLE_EDITOR_INPUT_ID, EditorsObserverTestEditorInputFactory)); + disposables.push(Registry.as(Extensions.Editors).registerEditor(EditorDescriptor.create(TestEditorControl, TEST_EDITOR_ID, 'My Test Editor For Editors Observer'), [new SyncDescriptor(TestEditorInput), new SyncDescriptor(EditorsObserverTestEditorInput)])); + }); + + teardown(() => { + dispose(disposables); + disposables = []; + }); + + + test('basics (single 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 EditorsObserver(part, new TestStorageService()); + + let historyChangeListenerCalled = false; + const listener = history.onDidChange(() => { + historyChangeListenerCalled = true; + }); + + let currentHistory = history.editors; + assert.equal(currentHistory.length, 0); + assert.equal(historyChangeListenerCalled, false); + + const input1 = new EditorsObserverTestEditorInput(URI.parse('foo://bar1')); + + await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: 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 EditorsObserverTestEditorInput(URI.parse('foo://bar2')); + const input3 = new EditorsObserverTestEditorInput(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(); + }); + + test('basics (multi group)', async () => { + const instantiationService = workbenchInstantiationService(); + + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); + + await part.whenRestored; + + const rootGroup = part.activeGroup; + + const history = new EditorsObserver(part, new TestStorageService()); + + let currentHistory = history.editors; + assert.equal(currentHistory.length, 0); + + const sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); + + const input1 = new EditorsObserverTestEditorInput(URI.parse('foo://bar1')); + + 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); + + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); + + 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); + + // Opening an editor inactive should not change + // the most recent editor, but rather put it behind + const input2 = new EditorsObserverTestEditorInput(URI.parse('foo://bar2')); + + await rootGroup.openEditor(input2, EditorOptions.create({ inactive: true })); + + 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 EditorsObserver(part, new TestStorageService()); + + const input1 = new EditorsObserverTestEditorInput(URI.parse('foo://bar1')); + const input2 = new EditorsObserverTestEditorInput(URI.parse('foo://bar2')); + const input3 = new EditorsObserverTestEditorInput(URI.parse('foo://bar3')); + + 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 })); + + 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); + + 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 EditorsObserverTestEditorInput(URI.parse('foo://bar1')); + const input2 = new EditorsObserverTestEditorInput(URI.parse('foo://bar2')); + const input3 = new EditorsObserverTestEditorInput(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 })); + + const storage = new TestStorageService(); + const history = new EditorsObserver(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); + + storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); + + const restoredHistory = new EditorsObserver(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); + + part.dispose(); + }); + + 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); + + await part.whenRestored; + + const rootGroup = part.activeGroup; + + const input1 = new EditorsObserverTestEditorInput(URI.parse('foo://bar1')); + const input2 = new EditorsObserverTestEditorInput(URI.parse('foo://bar2')); + const input3 = new EditorsObserverTestEditorInput(URI.parse('foo://bar3')); + + 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 storage = new TestStorageService(); + const history = new EditorsObserver(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); + + storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); + + const restoredHistory = new EditorsObserver(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); + + 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)); + + const part = instantiationService.createInstance(EditorPart); + part.create(document.createElement('div')); + part.layout(400, 300); + + await part.whenRestored; + + const rootGroup = part.activeGroup; + + const input1 = new TestEditorInput(URI.parse('foo://bar1')); + + await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); + + const storage = new TestStorageService(); + const history = new EditorsObserver(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); + + storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); + + const restoredHistory = new EditorsObserver(part, storage); + await part.whenRestored; + + currentHistory = restoredHistory.editors; + assert.equal(currentHistory.length, 0); + + part.dispose(); + }); + + 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')); + + 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); + + historyService.openPreviouslyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input1); + + historyService.openNextRecentlyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input2); + + historyService.openPreviouslyUsedEditor(part.activeGroup.id); + assert.equal(part.activeGroup.activeEditor, input1); + + historyService.openNextRecentlyUsedEditor(part.activeGroup.id); + assert.equal(part.activeGroup.activeEditor, input2); + + part.dispose(); + }); + + 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 sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); + + 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.openNextRecentlyUsedEditor(); + assert.equal(part.activeGroup, sideGroup); + assert.equal(sideGroup.activeEditor, input2); + + part.dispose(); + }); + + 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')); + + 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); + + await timeout(0); + await part.activeGroup.openEditor(input4, EditorOptions.create({ pinned: true })); + + historyService.openPreviouslyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input2); + + historyService.openNextRecentlyUsedEditor(); + assert.equal(part.activeGroup.activeEditor, input4); + + part.dispose(); + }); +}); diff --git a/src/vs/workbench/services/history/browser/history.ts b/src/vs/workbench/services/history/browser/history.ts index 009ad65946f..9d38b5e6747 100644 --- a/src/vs/workbench/services/history/browser/history.ts +++ b/src/vs/workbench/services/history/browser/history.ts @@ -7,7 +7,7 @@ import { onUnexpectedError } from 'vs/base/common/errors'; import { URI, UriComponents } from 'vs/base/common/uri'; import { IEditor } from 'vs/editor/common/editorCommon'; import { ITextEditorOptions, IResourceInput, ITextEditorSelection } from 'vs/platform/editor/common/editor'; -import { IEditorInput, IEditor as IBaseEditor, Extensions as EditorExtensions, EditorInput, IEditorCloseEvent, IEditorInputFactoryRegistry, toResource, IEditorIdentifier, GroupIdentifier, Extensions } from 'vs/workbench/common/editor'; +import { IEditorInput, IEditor as IBaseEditor, Extensions as EditorExtensions, EditorInput, IEditorCloseEvent, IEditorInputFactoryRegistry, toResource, IEditorIdentifier, GroupIdentifier } from 'vs/workbench/common/editor'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IHistoryService } from 'vs/workbench/services/history/common/history'; import { FileChangesEvent, IFileService, FileChangeType, FILES_EXCLUDE_CONFIG } from 'vs/platform/files/common/files'; @@ -16,9 +16,9 @@ import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace import { dispose, Disposable, DisposableStore } from 'vs/base/common/lifecycle'; import { IStorageService, StorageScope } from 'vs/platform/storage/common/storage'; import { Registry } from 'vs/platform/registry/common/platform'; -import { Event, Emitter } from 'vs/base/common/event'; +import { Event } from 'vs/base/common/event'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; -import { IEditorGroupsService, IEditorGroup, EditorsOrder, GroupChangeKind, GroupsOrder } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorGroupsService, EditorsOrder } from 'vs/workbench/services/editor/common/editorGroupsService'; import { getCodeEditor, ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { getExcludes, ISearchConfiguration } from 'vs/workbench/services/search/common/search'; import { IExpression } from 'vs/base/common/glob'; @@ -34,9 +34,6 @@ import { withNullAsUndefined } from 'vs/base/common/types'; import { addDisposableListener, EventType, EventHelper } from 'vs/base/browser/dom'; import { IWorkspacesService } from 'vs/platform/workspaces/common/workspaces'; import { Schemas } from 'vs/base/common/network'; -import { LinkedMap, Touch } from 'vs/base/common/map'; - -//#region Text Editor State helper /** * Stores the selection & view state of an editor and allows to compare it to other selection states. @@ -86,295 +83,6 @@ export class TextEditorState { } } -//#endregion - -//#region Editors History - -interface ISerializedEditorHistory { - history: ISerializedEditorIdentifier[]; -} - -interface ISerializedEditorIdentifier { - groupId: GroupIdentifier; - index: number; -} - -/** - * A history of opened editors across all editor groups by most recently used. - * Rules: - * - the last editor in the history is the one most recently activated - * - the first editor in the history is the one that was activated the longest time ago - * - an editor that opens inactive will be placed behind the currently active editor - */ -export class EditorsHistory extends Disposable { - - private static readonly STORAGE_KEY = 'history.editors'; - - private readonly keyMap = new Map>(); - private readonly mostRecentEditorsMap = new LinkedMap(); - - private readonly _onDidChange = this._register(new Emitter()); - readonly onDidChange = this._onDidChange.event; - - get editors(): IEditorIdentifier[] { - return this.mostRecentEditorsMap.values(); - } - - constructor( - @IEditorGroupsService private editorGroupsService: IEditorGroupsService, - @IStorageService private readonly storageService: IStorageService - ) { - super(); - - this.registerListeners(); - } - - private registerListeners(): void { - this._register(this.storageService.onWillSaveState(() => this.saveState())); - this._register(this.editorGroupsService.onDidAddGroup(group => this.onGroupAdded(group))); - - this.editorGroupsService.whenRestored.then(() => this.loadState()); - } - - private onGroupAdded(group: IEditorGroup): void { - - // Make sure to add any already existing editor - // of the new group into our history in LRU order - const groupEditorsMru = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); - for (let i = groupEditorsMru.length - 1; i >= 0; i--) { - this.addMostRecentEditor(group, groupEditorsMru[i], false /* is not active */); - } - - // Make sure that active editor is put as first if group is active - if (this.editorGroupsService.activeGroup === group && group.activeEditor) { - this.addMostRecentEditor(group, group.activeEditor, true /* is active */); - } - - // Group Listeners - this.registerGroupListeners(group); - } - - private registerGroupListeners(group: IEditorGroup): void { - const groupDisposables = new DisposableStore(); - groupDisposables.add(group.onDidGroupChange(e => { - switch (e.kind) { - - // Group gets active: put active editor as most recent - case GroupChangeKind.GROUP_ACTIVE: { - if (this.editorGroupsService.activeGroup === group && group.activeEditor) { - this.addMostRecentEditor(group, group.activeEditor, true /* is active */); - } - - break; - } - - // Editor gets active: put active editor as most recent - // if group is active, otherwise second most recent - case GroupChangeKind.EDITOR_ACTIVE: { - if (e.editor) { - this.addMostRecentEditor(group, e.editor, this.editorGroupsService.activeGroup === group); - } - - break; - } - - // Editor opens: put it as second most recent - case GroupChangeKind.EDITOR_OPEN: { - if (e.editor) { - this.addMostRecentEditor(group, e.editor, false /* is not active */); - } - - break; - } - - // Editor closes: remove from recently opened - case GroupChangeKind.EDITOR_CLOSE: { - if (e.editor) { - this.removeMostRecentEditor(group, e.editor); - } - - break; - } - } - })); - - // Make sure to cleanup on dispose - Event.once(group.onWillDispose)(() => dispose(groupDisposables)); - } - - private addMostRecentEditor(group: IEditorGroup, editor: IEditorInput, isActive: boolean): void { - const key = this.ensureKey(group, editor); - const mostRecentEditor = this.mostRecentEditorsMap.first; - - // Active or first entry: add to end of map - if (isActive || !mostRecentEditor) { - this.mostRecentEditorsMap.set(key, key, mostRecentEditor ? Touch.AsOld /* make first */ : undefined); - } - - // Otherwise: insert before most recent - else { - // we have most recent editors. as such we - // put this newly opened editor right before - // the current most recent one because it cannot - // be the most recently active one unless - // it becomes active. but it is still more - // active then any other editor in the list. - this.mostRecentEditorsMap.set(key, key, Touch.AsOld /* make first */); - this.mostRecentEditorsMap.set(mostRecentEditor, mostRecentEditor, Touch.AsOld /* make first */); - } - - // Event - this._onDidChange.fire(); - } - - private removeMostRecentEditor(group: IEditorGroup, editor: IEditorInput): void { - const key = this.findKey(group, editor); - if (key) { - - // Remove from most recent editors - this.mostRecentEditorsMap.delete(key); - - // Remove from key map - const map = this.keyMap.get(group.id); - if (map && map.delete(key.editor) && map.size === 0) { - this.keyMap.delete(group.id); - } - - // Event - this._onDidChange.fire(); - } - } - - private findKey(group: IEditorGroup, editor: IEditorInput): IEditorIdentifier | undefined { - const groupMap = this.keyMap.get(group.id); - if (!groupMap) { - return undefined; - } - - return groupMap.get(editor); - } - - private ensureKey(group: IEditorGroup, editor: IEditorInput): IEditorIdentifier { - let groupMap = this.keyMap.get(group.id); - if (!groupMap) { - groupMap = new Map(); - - this.keyMap.set(group.id, groupMap); - } - - let key = groupMap.get(editor); - if (!key) { - key = { groupId: group.id, editor }; - groupMap.set(editor, key); - } - - return key; - } - - private saveState(): void { - if (this.mostRecentEditorsMap.isEmpty()) { - this.storageService.remove(EditorsHistory.STORAGE_KEY, StorageScope.WORKSPACE); - } else { - this.storageService.store(EditorsHistory.STORAGE_KEY, JSON.stringify(this.serialize()), StorageScope.WORKSPACE); - } - } - - private serialize(): ISerializedEditorHistory { - const registry = Registry.as(Extensions.EditorInputFactories); - - const history = this.mostRecentEditorsMap.values(); - const mapGroupToSerializableEditorsOfGroup = new Map(); - - return { - history: coalesce(history.map(({ editor, groupId }) => { - - // Find group for entry - const group = this.editorGroupsService.getGroup(groupId); - if (!group) { - return undefined; - } - - // Find serializable editors of group - let serializableEditorsOfGroup = mapGroupToSerializableEditorsOfGroup.get(group); - if (!serializableEditorsOfGroup) { - serializableEditorsOfGroup = group.getEditors(EditorsOrder.SEQUENTIAL).filter(editor => { - const factory = registry.getEditorInputFactory(editor.getTypeId()); - - return factory?.canSerialize(editor); - }); - mapGroupToSerializableEditorsOfGroup.set(group, serializableEditorsOfGroup); - } - - // Only store the index of the editor of that group - // which can be undefined if the editor is not serializable - const index = serializableEditorsOfGroup.indexOf(editor); - if (index === -1) { - return undefined; - } - - return { groupId, index }; - })) - }; - } - - private loadState(): void { - const serialized = this.storageService.get(EditorsHistory.STORAGE_KEY, StorageScope.WORKSPACE); - - // Previous state: - if (serialized) { - - // Load history map from persisted state - this.deserialize(JSON.parse(serialized)); - } - - // No previous state: best we can do is add each editor - // from oldest to most recently used editor group - else { - const groups = this.editorGroupsService.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE); - for (let i = groups.length - 1; i >= 0; i--) { - const group = groups[i]; - const groupEditorsMru = group.getEditors(EditorsOrder.MOST_RECENTLY_ACTIVE); - for (let i = groupEditorsMru.length - 1; i >= 0; i--) { - this.addMostRecentEditor(group, groupEditorsMru[i], true /* enforce as active to preserve order */); - } - } - } - - // Ensure we listen on group changes for those that exist on startup - for (const group of this.editorGroupsService.groups) { - this.registerGroupListeners(group); - } - } - - deserialize(serialized: ISerializedEditorHistory): void { - const mapValues: [IEditorIdentifier, IEditorIdentifier][] = []; - - for (const { groupId, index } of serialized.history) { - - // Find group for entry - const group = this.editorGroupsService.getGroup(groupId); - if (!group) { - continue; - } - - // Find editor for entry - const editor = group.getEditorByIndex(index); - if (!editor) { - continue; - } - - // Make sure key is registered as well - const editorIdentifier = this.ensureKey(group, editor); - mapValues.push([editorIdentifier, editorIdentifier]); - } - - // Fill map with deserialized values - this.mostRecentEditorsMap.fromJSON(mapValues); - } -} - -//#endregion - interface ISerializedEditorHistoryEntry { resourceJSON?: object; editorInputJSON?: { typeId: string; deserialized: string; }; @@ -424,7 +132,7 @@ export class HistoryService extends Disposable implements IHistoryService { this._register(this.storageService.onWillSaveState(() => this.saveState())); this._register(this.fileService.onFileChanges(event => this.onFileChanges(event))); this._register(this.resourceFilter.onExpressionChange(() => this.removeExcludedFromHistory())); - this._register(this.mostRecentlyUsedOpenEditors.onDidChange(() => this.handleEditorEventInRecentEditorsStack())); + this._register(this.editorService.onDidMostRecentlyActiveEditorsChange(() => this.handleEditorEventInRecentEditorsStack())); // if the service is created late enough that an editor is already opened // make sure to trigger the onActiveEditorChanged() to track the editor @@ -1102,7 +810,7 @@ export class HistoryService extends Disposable implements IHistoryService { this.editorHistoryListeners.clear(); } - getHistory(): Array { + getHistory(): ReadonlyArray { this.ensureHistoryLoaded(); return this.history.slice(0); @@ -1266,12 +974,10 @@ export class HistoryService extends Disposable implements IHistoryService { //#region Editor Most Recently Used History - private readonly mostRecentlyUsedOpenEditors = this._register(this.instantiationService.createInstance(EditorsHistory)); - - private recentlyUsedEditorsStack: IEditorIdentifier[] | undefined = undefined; + private recentlyUsedEditorsStack: ReadonlyArray | undefined = undefined; private recentlyUsedEditorsStackIndex = 0; - private recentlyUsedEditorsInGroupStack: IEditorIdentifier[] | undefined = undefined; + private recentlyUsedEditorsInGroupStack: ReadonlyArray | undefined = undefined; private recentlyUsedEditorsInGroupStackIndex = 0; private navigatingInRecentlyUsedEditorsStack = false; @@ -1309,15 +1015,15 @@ export class HistoryService extends Disposable implements IHistoryService { } } - private ensureRecentlyUsedStack(indexModifier: (index: number) => number, groupId?: GroupIdentifier): [IEditorIdentifier[], number] { - let editors: IEditorIdentifier[]; + private ensureRecentlyUsedStack(indexModifier: (index: number) => number, groupId?: GroupIdentifier): [ReadonlyArray, number] { + let editors: ReadonlyArray; let index: number; const group = typeof groupId === 'number' ? this.editorGroupService.getGroup(groupId) : undefined; // Across groups if (!group) { - editors = this.recentlyUsedEditorsStack || this.mostRecentlyUsedOpenEditors.editors; + editors = this.recentlyUsedEditorsStack || this.editorService.mostRecentlyActiveEditors; index = this.recentlyUsedEditorsStackIndex; } @@ -1362,8 +1068,8 @@ export class HistoryService extends Disposable implements IHistoryService { } } - getMostRecentlyUsedOpenEditors(): Array { - return this.mostRecentlyUsedOpenEditors.editors; + getMostRecentlyUsedOpenEditors(): ReadonlyArray { + return this.editorService.mostRecentlyActiveEditors; } //#endregion diff --git a/src/vs/workbench/services/history/common/history.ts b/src/vs/workbench/services/history/common/history.ts index 76f695a27e1..efb224beb2f 100644 --- a/src/vs/workbench/services/history/common/history.ts +++ b/src/vs/workbench/services/history/common/history.ts @@ -57,7 +57,7 @@ export interface IHistoryService { /** * Get the entire history of editors that were opened. */ - getHistory(): Array; + getHistory(): ReadonlyArray; /** * Looking at the editor history, returns the workspace root of the last file that was @@ -91,5 +91,5 @@ export interface IHistoryService { /** * Get a list of most recently used editors that are open. */ - getMostRecentlyUsedOpenEditors(): Array; + getMostRecentlyUsedOpenEditors(): ReadonlyArray; } diff --git a/src/vs/workbench/services/history/test/history.test.ts b/src/vs/workbench/services/history/test/history.test.ts index e032184cb5a..67c5b2fca7e 100644 --- a/src/vs/workbench/services/history/test/history.test.ts +++ b/src/vs/workbench/services/history/test/history.test.ts @@ -11,18 +11,16 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { EditorPart } from 'vs/workbench/browser/parts/editor/editorPart'; import { IEditorRegistry, EditorDescriptor, Extensions } from 'vs/workbench/browser/editor'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; -import { GroupDirection, IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; -import { EditorActivation, IEditorModel } from 'vs/platform/editor/common/editor'; +import { IEditorGroupsService } from 'vs/workbench/services/editor/common/editorGroupsService'; +import { IEditorModel } from 'vs/platform/editor/common/editor'; import { BaseEditor } from 'vs/workbench/browser/parts/editor/baseEditor'; import { NullTelemetryService } from 'vs/platform/telemetry/common/telemetryUtils'; import { TestThemeService } from 'vs/platform/theme/test/common/testThemeService'; 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 { HistoryService } from 'vs/workbench/services/history/browser/history'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; 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'; @@ -180,418 +178,6 @@ suite('HistoryService', function () { part.dispose(); }); - - suite('EditorHistory', function () { - - test('basics (single 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()); - - let historyChangeListenerCalled = false; - const listener = history.onDidChange(() => { - historyChangeListenerCalled = true; - }); - - let currentHistory = history.editors; - assert.equal(currentHistory.length, 0); - assert.equal(historyChangeListenerCalled, false); - - const input1 = new HistoryTestEditorInput(URI.parse('foo://bar1')); - - await part.activeGroup.openEditor(input1, EditorOptions.create({ pinned: 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(); - }); - - test('basics (multi group)', async () => { - const instantiationService = workbenchInstantiationService(); - - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); - - await part.whenRestored; - - const rootGroup = part.activeGroup; - - 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 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); - - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true, activation: EditorActivation.ACTIVATE })); - - 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); - - // Opening an editor inactive should not change - // the most recent editor, but rather put it behind - const input2 = new HistoryTestEditorInput(URI.parse('foo://bar2')); - - await rootGroup.openEditor(input2, EditorOptions.create({ inactive: true })); - - 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 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; - - 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); - - 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')); - - 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; - - 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 }); - - 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); - - part.dispose(); - }); - - 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); - - 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 })); - - 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; - - 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 }); - - 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); - - 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)); - - const part = instantiationService.createInstance(EditorPart); - part.create(document.createElement('div')); - part.layout(400, 300); - - await part.whenRestored; - - const rootGroup = part.activeGroup; - - const input1 = new TestEditorInput(URI.parse('foo://bar1')); - - await rootGroup.openEditor(input1, EditorOptions.create({ pinned: true })); - - 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); - - storage._onWillSaveState.fire({ reason: WillSaveStateReason.SHUTDOWN }); - - const restoredHistory = new EditorsHistory(part, storage); - await part.whenRestored; - - currentHistory = restoredHistory.editors; - assert.equal(currentHistory.length, 0); - - part.dispose(); - }); - - 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')); - - 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); - - historyService.openPreviouslyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input1); - - historyService.openNextRecentlyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input2); - - historyService.openPreviouslyUsedEditor(part.activeGroup.id); - assert.equal(part.activeGroup.activeEditor, input1); - - historyService.openNextRecentlyUsedEditor(part.activeGroup.id); - assert.equal(part.activeGroup.activeEditor, input2); - - part.dispose(); - }); - - 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 sideGroup = part.addGroup(rootGroup, GroupDirection.RIGHT); - - 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.openNextRecentlyUsedEditor(); - assert.equal(part.activeGroup, sideGroup); - assert.equal(sideGroup.activeEditor, input2); - - part.dispose(); - }); - - 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')); - - 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); - - await timeout(0); - await part.activeGroup.openEditor(input4, EditorOptions.create({ pinned: true })); - - historyService.openPreviouslyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input2); - - historyService.openNextRecentlyUsedEditor(); - assert.equal(part.activeGroup.activeEditor, input4); - - part.dispose(); - }); - }); }); diff --git a/src/vs/workbench/test/workbenchTestServices.ts b/src/vs/workbench/test/workbenchTestServices.ts index 3d43c156d2c..bed812a691c 100644 --- a/src/vs/workbench/test/workbenchTestServices.ts +++ b/src/vs/workbench/test/workbenchTestServices.ts @@ -374,7 +374,7 @@ export class TestHistoryService implements IHistoryService { remove(_input: IEditorInput | IResourceInput): void { } clear(): void { } clearRecentlyOpened(): void { } - getHistory(): Array { return []; } + getHistory(): ReadonlyArray { return []; } openNextRecentlyUsedEditor(group?: GroupIdentifier): void { } openPreviouslyUsedEditor(group?: GroupIdentifier): void { } getMostRecentlyUsedOpenEditors(): Array { return []; } @@ -902,11 +902,13 @@ export class TestEditorService implements EditorServiceImpl { onDidVisibleEditorsChange: Event = Event.None; onDidCloseEditor: Event = Event.None; onDidOpenEditorFail: Event = Event.None; + onDidMostRecentlyActiveEditorsChange: Event = Event.None; activeControl!: IVisibleEditor; activeTextEditorWidget: any; activeEditor!: IEditorInput; editors: ReadonlyArray = []; + mostRecentlyActiveEditors: ReadonlyArray = []; visibleControls: ReadonlyArray = []; visibleTextEditorWidgets = []; visibleEditors: ReadonlyArray = []; -- GitLab