From 901ba0737af2aea7dcbadbb9f47c6790989bd09a Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Thu, 4 Jan 2024 11:28:43 +0100 Subject: [PATCH] Allow to store/resume open editors across Continue On transitions (fix #193704) (#201631) * Allow to store/resume open editors across Continue On transitions (fix #193704) * address feedback --- .../browser/parts/editor/editorPart.ts | 100 ++++++++++----- .../browser/parts/editor/editorParts.ts | 117 +++++++++++++----- src/vs/workbench/common/component.ts | 12 +- src/vs/workbench/common/memento.ts | 69 +++++++---- .../test/browser/notebookServiceImpl.test.ts | 1 + 5 files changed, 217 insertions(+), 82 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/editorPart.ts b/src/vs/workbench/browser/parts/editor/editorPart.ts index be83c793d58..48818240c36 100644 --- a/src/vs/workbench/browser/parts/editor/editorPart.ts +++ b/src/vs/workbench/browser/parts/editor/editorPart.ts @@ -18,7 +18,7 @@ import { IEditorGroupView, getEditorPartOptions, impactsEditorPartOptions, IEdit import { EditorGroupView } from 'vs/workbench/browser/parts/editor/editorGroupView'; import { IConfigurationService, IConfigurationChangeEvent } from 'vs/platform/configuration/common/configuration'; import { IDisposable, dispose, toDisposable, DisposableStore } from 'vs/base/common/lifecycle'; -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ISerializedEditorGroupModel, isSerializedEditorGroupModel } from 'vs/workbench/common/editor/editorGroupModel'; import { EditorDropTarget } from 'vs/workbench/browser/parts/editor/editorDropTarget'; import { Color } from 'vs/base/common/color'; @@ -135,7 +135,7 @@ export class EditorPart extends Part implements IEditorPart, IEditorGroupsView { //#endregion - private readonly workspaceMemento = this.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + private readonly workspaceMemento = this.getMemento(StorageScope.WORKSPACE, StorageTarget.USER); private readonly profileMemento = this.getMemento(StorageScope.PROFILE, StorageTarget.MACHINE); private readonly groupViews = new Map(); @@ -172,6 +172,7 @@ export class EditorPart extends Part implements IEditorPart, IEditorGroupsView { private registerListeners(): void { this._register(this.configurationService.onDidChangeConfiguration(e => this.onConfigurationUpdated(e))); this._register(this.themeService.onDidFileIconThemeChange(() => this.handleChangedPartOptions())); + this._register(this.onDidChangeMementoValue(StorageScope.WORKSPACE, this._store)(e => this.onDidChangeMementoState(e))); } private onConfigurationUpdated(event: IConfigurationChangeEvent): void { @@ -493,23 +494,7 @@ export class EditorPart extends Part implements IEditorPart, IEditorGroupsView { }); // Recreate gridwidget with descriptor - this.doCreateGridControlWithState(gridDescriptor, activeGroup.id, currentGroupViews); - - // Layout - this.doLayout(this._contentDimension); - - // Update container - this.updateContainer(); - - // Events for groups that got added - for (const groupView of this.getGroups(GroupsOrder.GRID_APPEARANCE)) { - if (!currentGroupViews.includes(groupView)) { - this._onDidAddGroup.fire(groupView); - } - } - - // Notify group index change given layout has changed - this.notifyGroupIndexChange(); + this.doApplyGridState(gridDescriptor, activeGroup.id, currentGroupViews); // Restore focus as needed if (restoreFocus) { @@ -712,15 +697,21 @@ export class EditorPart extends Part implements IEditorPart, IEditorGroupsView { } private doRestoreGroup(group: IEditorGroupView): void { - if (this.gridWidget) { - if (this.hasMaximizedGroup() && !this.isGroupMaximized(group)) { - this.unmaximizeGroup(); - } + if (!this.gridWidget) { + return; // method is called as part of state restore very early + } + if (this.hasMaximizedGroup() && !this.isGroupMaximized(group)) { + this.unmaximizeGroup(); + } + + try { const viewSize = this.gridWidget.getViewSize(group); if (viewSize.width === group.minimumWidth || viewSize.height === group.minimumHeight) { this.arrangeGroups(GroupsArrangement.EXPAND, group); } + } catch (error) { + // ignore: method might be called too early before view is known to grid } } @@ -1171,19 +1162,19 @@ export class EditorPart extends Part implements IEditorPart, IEditorGroupsView { } private doCreateGridControlWithPreviousState(): boolean { - const uiState: IEditorPartUIState | undefined = this.loadState(); - if (uiState?.serializedGrid) { + const state: IEditorPartUIState | undefined = this.loadState(); + if (state?.serializedGrid) { try { // MRU - this.mostRecentActiveGroups = uiState.mostRecentActiveGroups; + this.mostRecentActiveGroups = state.mostRecentActiveGroups; // Grid Widget - this.doCreateGridControlWithState(uiState.serializedGrid, uiState.activeGroup); + this.doCreateGridControlWithState(state.serializedGrid, state.activeGroup); } catch (error) { // Log error - onUnexpectedError(new Error(`Error restoring editor grid widget: ${error} (with state: ${JSON.stringify(uiState)})`)); + onUnexpectedError(new Error(`Error restoring editor grid widget: ${error} (with state: ${JSON.stringify(state)})`)); // Clear any state we have from the failing restore this.disposeGroups(); @@ -1345,6 +1336,59 @@ export class EditorPart extends Part implements IEditorPart, IEditorGroupsView { }; } + async applyState(state: IEditorPartUIState): Promise { + + // Close all opened editors and dispose groups + for (const group of this.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + const closed = await group.closeAllEditors(); + if (!closed) { + return false; + } + } + this.disposeGroups(); + + // MRU + this.mostRecentActiveGroups = state.mostRecentActiveGroups; + + // Grid Widget + this.doApplyGridState(state.serializedGrid, state.activeGroup); + + return true; + } + + private doApplyGridState(gridState: ISerializedGrid, activeGroupId: GroupIdentifier, editorGroupViewsToReuse?: IEditorGroupView[]): void { + + // Recreate grid widget from state + this.doCreateGridControlWithState(gridState, activeGroupId, editorGroupViewsToReuse); + + // Layout + this.doLayout(this._contentDimension); + + // Update container + this.updateContainer(); + + // Events for groups that got added + for (const groupView of this.getGroups(GroupsOrder.GRID_APPEARANCE)) { + if (!editorGroupViewsToReuse?.includes(groupView)) { + this._onDidAddGroup.fire(groupView); + } + } + + // Notify group index change given layout has changed + this.notifyGroupIndexChange(); + } + + private onDidChangeMementoState(e: IStorageValueChangeEvent): void { + if (e.external && e.scope === StorageScope.WORKSPACE) { + this.reloadMemento(e.scope); + + const state = this.loadState(); + if (state) { + this.applyState(state); + } + } + } + toJSON(): object { return { type: Parts.EDITOR_PART diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 5ec469b889d..35b9750480d 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -16,7 +16,7 @@ import { distinct, firstOrDefault } from 'vs/base/common/arrays'; import { AuxiliaryEditorPart, IAuxiliaryEditorPartOpenOptions } from 'vs/workbench/browser/parts/editor/auxiliaryEditorPart'; import { MultiWindowParts } from 'vs/workbench/browser/part'; import { DeferredPromise } from 'vs/base/common/async'; -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { IRectangle } from 'vs/platform/window/common/window'; import { getWindow } from 'vs/base/browser/dom'; @@ -51,6 +51,11 @@ export class EditorParts extends MultiWindowParts implements IEditor this._register(this.registerPart(this.mainPart)); this.restoreParts(); + this.registerListeners(); + } + + private registerListeners(): void { + this._register(this.onDidChangeMementoValue(StorageScope.WORKSPACE, this._store)(e => this.onDidChangeMementoState(e))); } protected createMainEditorPart(): MainEditorPart { @@ -180,7 +185,7 @@ export class EditorParts extends MultiWindowParts implements IEditor private static readonly EDITOR_PARTS_UI_STATE_STORAGE_KEY = 'editorparts.state'; - private readonly workspaceMemento = this.getMemento(StorageScope.WORKSPACE, StorageTarget.MACHINE); + private readonly workspaceMemento = this.getMemento(StorageScope.WORKSPACE, StorageTarget.USER); private _isReady = false; get isReady(): boolean { return this._isReady; } @@ -204,34 +209,12 @@ export class EditorParts extends MultiWindowParts implements IEditor // that restoring was not attempted because specific // editors were opened. if (this.mainPart.willRestoreState) { - const uiState: IEditorPartsUIState | undefined = this.workspaceMemento[EditorParts.EDITOR_PARTS_UI_STATE_STORAGE_KEY]; - if (uiState?.auxiliary.length) { - const auxiliaryEditorPartPromises: Promise[] = []; - - // Create auxiliary editor parts - for (const auxiliaryEditorPartState of uiState.auxiliary) { - auxiliaryEditorPartPromises.push(this.createAuxiliaryEditorPart({ - bounds: auxiliaryEditorPartState.bounds, - state: auxiliaryEditorPartState.state, - zoomLevel: auxiliaryEditorPartState.zoomLevel - })); - } - - // Await creation - await Promise.allSettled(auxiliaryEditorPartPromises); - - // Update MRU list - if (uiState.mru.length === this.parts.length) { - this.mostRecentActiveParts = uiState.mru.map(index => this.parts[index]); - } else { - this.mostRecentActiveParts = [...this.parts]; - } + const state = this.loadState(); + if (state) { + await this.restoreState(state); } } - // Await ready - await Promise.allSettled(this.parts.map(part => part.whenReady)); - const mostRecentActivePart = firstOrDefault(this.mostRecentActiveParts); mostRecentActivePart?.activeGroup.focus(); @@ -243,8 +226,21 @@ export class EditorParts extends MultiWindowParts implements IEditor this.whenRestoredPromise.complete(); } + private loadState(): IEditorPartsUIState | undefined { + return this.workspaceMemento[EditorParts.EDITOR_PARTS_UI_STATE_STORAGE_KEY]; + } + protected override saveState(): void { - const uiState: IEditorPartsUIState = { + const state = this.createState(); + if (state.auxiliary.length === 0) { + delete this.workspaceMemento[EditorParts.EDITOR_PARTS_UI_STATE_STORAGE_KEY]; + } else { + this.workspaceMemento[EditorParts.EDITOR_PARTS_UI_STATE_STORAGE_KEY] = state; + } + } + + private createState(): IEditorPartsUIState { + return { auxiliary: this.parts.filter(part => part !== this.mainPart).map(part => { return { state: part.createState(), @@ -273,11 +269,33 @@ export class EditorParts extends MultiWindowParts implements IEditor }), mru: this.mostRecentActiveParts.map(part => this.parts.indexOf(part)) }; + } - if (uiState.auxiliary.length === 0) { - delete this.workspaceMemento[EditorParts.EDITOR_PARTS_UI_STATE_STORAGE_KEY]; - } else { - this.workspaceMemento[EditorParts.EDITOR_PARTS_UI_STATE_STORAGE_KEY] = uiState; + private async restoreState(state: IEditorPartsUIState): Promise { + if (state.auxiliary.length) { + const auxiliaryEditorPartPromises: Promise[] = []; + + // Create auxiliary editor parts + for (const auxiliaryEditorPartState of state.auxiliary) { + auxiliaryEditorPartPromises.push(this.createAuxiliaryEditorPart({ + bounds: auxiliaryEditorPartState.bounds, + state: auxiliaryEditorPartState.state, + zoomLevel: auxiliaryEditorPartState.zoomLevel + })); + } + + // Await creation + await Promise.allSettled(auxiliaryEditorPartPromises); + + // Update MRU list + if (state.mru.length === this.parts.length) { + this.mostRecentActiveParts = state.mru.map(index => this.parts[index]); + } else { + this.mostRecentActiveParts = [...this.parts]; + } + + // Await ready + await Promise.allSettled(this.parts.map(part => part.whenReady)); } } @@ -285,6 +303,41 @@ export class EditorParts extends MultiWindowParts implements IEditor return this.parts.some(part => part.hasRestorableState); } + private onDidChangeMementoState(e: IStorageValueChangeEvent): void { + if (e.external && e.scope === StorageScope.WORKSPACE) { + this.reloadMemento(e.scope); + + const state = this.loadState(); + if (state) { + this.applyState(state); + } + } + } + + private async applyState(state: IEditorPartsUIState): Promise { + + // Close all editors and auxiliary parts first + for (const part of this.parts) { + if (part === this.mainPart) { + continue; // main part takes care on its own + } + + for (const group of part.getGroups(GroupsOrder.MOST_RECENTLY_ACTIVE)) { + const closed = await group.closeAllEditors(); + if (!closed) { + return false; + } + } + + (part as unknown as IAuxiliaryEditorPart).close(); + } + + // Restore auxiliary state + await this.restoreState(state); + + return true; + } + //#endregion //#region Events diff --git a/src/vs/workbench/common/component.ts b/src/vs/workbench/common/component.ts index f4454acc56f..6c25dc9d977 100644 --- a/src/vs/workbench/common/component.ts +++ b/src/vs/workbench/common/component.ts @@ -5,7 +5,9 @@ import { Memento, MementoObject } from 'vs/workbench/common/memento'; import { IThemeService, Themable } from 'vs/platform/theme/common/themeService'; -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { Event } from 'vs/base/common/event'; export class Component extends Themable { @@ -39,6 +41,14 @@ export class Component extends Themable { return this.memento.getMemento(scope, target); } + protected reloadMemento(scope: StorageScope): void { + return this.memento.reloadMemento(scope); + } + + protected onDidChangeMementoValue(scope: StorageScope, disposables: DisposableStore): Event { + return this.memento.onDidChangeValue(scope, disposables); + } + protected saveState(): void { // Subclasses to implement for storing state } diff --git a/src/vs/workbench/common/memento.ts b/src/vs/workbench/common/memento.ts index 4c6cea09123..ad0e9e50fe3 100644 --- a/src/vs/workbench/common/memento.ts +++ b/src/vs/workbench/common/memento.ts @@ -3,9 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { IStorageService, IStorageValueChangeEvent, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { isEmptyObject } from 'vs/base/common/types'; import { onUnexpectedError } from 'vs/base/common/errors'; +import { DisposableStore } from 'vs/base/common/lifecycle'; +import { Event } from 'vs/base/common/event'; export type MementoObject = { [key: string]: any }; @@ -25,8 +27,6 @@ export class Memento { getMemento(scope: StorageScope, target: StorageTarget): MementoObject { switch (scope) { - - // Scope by Workspace case StorageScope.WORKSPACE: { let workspaceMemento = Memento.workspaceMementos.get(this.id); if (!workspaceMemento) { @@ -37,7 +37,6 @@ export class Memento { return workspaceMemento.getMemento(); } - // Scope Profile case StorageScope.PROFILE: { let profileMemento = Memento.profileMementos.get(this.id); if (!profileMemento) { @@ -48,7 +47,6 @@ export class Memento { return profileMemento.getMemento(); } - // Scope Application case StorageScope.APPLICATION: { let applicationMemento = Memento.applicationMementos.get(this.id); if (!applicationMemento) { @@ -61,12 +59,33 @@ export class Memento { } } + onDidChangeValue(scope: StorageScope, disposables: DisposableStore): Event { + return this.storageService.onDidChangeValue(scope, this.id, disposables); + } + saveMemento(): void { Memento.workspaceMementos.get(this.id)?.save(); Memento.profileMementos.get(this.id)?.save(); Memento.applicationMementos.get(this.id)?.save(); } + reloadMemento(scope: StorageScope): void { + let memento: ScopedMemento | undefined; + switch (scope) { + case StorageScope.APPLICATION: + memento = Memento.applicationMementos.get(this.id); + break; + case StorageScope.PROFILE: + memento = Memento.profileMementos.get(this.id); + break; + case StorageScope.WORKSPACE: + memento = Memento.workspaceMementos.get(this.id); + break; + } + + memento?.reload(); + } + static clear(scope: StorageScope): void { switch (scope) { case StorageScope.WORKSPACE: @@ -84,36 +103,44 @@ export class Memento { class ScopedMemento { - private readonly mementoObj: MementoObject; + private mementoObj: MementoObject; constructor(private id: string, private scope: StorageScope, private target: StorageTarget, private storageService: IStorageService) { - this.mementoObj = this.load(); + this.mementoObj = this.doLoad(); + } + + private doLoad(): MementoObject { + try { + return this.storageService.getObject(this.id, this.scope, {}); + } catch (error) { + // Seeing reports from users unable to open editors + // from memento parsing exceptions. Log the contents + // to diagnose further + // https://github.com/microsoft/vscode/issues/102251 + onUnexpectedError(`[memento]: failed to parse contents: ${error} (id: ${this.id}, scope: ${this.scope}, contents: ${this.storageService.get(this.id, this.scope)})`); + } + + return {}; } getMemento(): MementoObject { return this.mementoObj; } - private load(): MementoObject { - const memento = this.storageService.get(this.id, this.scope); - if (memento) { - try { - return JSON.parse(memento); - } catch (error) { - // Seeing reports from users unable to open editors - // from memento parsing exceptions. Log the contents - // to diagnose further - // https://github.com/microsoft/vscode/issues/102251 - onUnexpectedError(`[memento]: failed to parse contents: ${error} (id: ${this.id}, scope: ${this.scope}, contents: ${memento})`); - } + reload(): void { + + // Clear old + for (const name of Object.getOwnPropertyNames(this.mementoObj)) { + delete this.mementoObj[name]; } - return {}; + // Assign new + Object.assign(this.mementoObj, this.doLoad()); } save(): void { if (!isEmptyObject(this.mementoObj)) { - this.storageService.store(this.id, JSON.stringify(this.mementoObj), this.scope, this.target); + this.storageService.store(this.id, this.mementoObj, this.scope, this.target); } else { this.storageService.remove(this.id, this.scope); } diff --git a/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts b/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts index c204630f49d..647c96305ce 100644 --- a/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts +++ b/src/vs/workbench/contrib/notebook/test/browser/notebookServiceImpl.test.ts @@ -31,6 +31,7 @@ suite('NotebookProviderInfoStore', function () { new class extends mock() { override get() { return ''; } override store() { } + override getObject() { return {}; } }, new class extends mock() { override onDidRegisterExtensions = Event.None;