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
This commit is contained in:
Benjamin Pasero 2024-01-04 11:28:43 +01:00 committed by GitHub
parent f405cbff74
commit 901ba0737a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 217 additions and 82 deletions

View file

@ -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<GroupIdentifier, IEditorGroupView>();
@ -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<boolean> {
// 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

View file

@ -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<EditorPart> 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<EditorPart> 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<EditorPart> 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<IAuxiliaryEditorPart>[] = [];
// 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<EditorPart> 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<EditorPart> 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<void> {
if (state.auxiliary.length) {
const auxiliaryEditorPartPromises: Promise<IAuxiliaryEditorPart>[] = [];
// 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<EditorPart> 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<boolean> {
// 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

View file

@ -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<IStorageValueChangeEvent> {
return this.memento.onDidChangeValue(scope, disposables);
}
protected saveState(): void {
// Subclasses to implement for storing state
}

View file

@ -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<IStorageValueChangeEvent> {
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<MementoObject>(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);
}

View file

@ -31,6 +31,7 @@ suite('NotebookProviderInfoStore', function () {
new class extends mock<IStorageService>() {
override get() { return ''; }
override store() { }
override getObject() { return {}; }
},
new class extends mock<IExtensionService>() {
override onDidRegisterExtensions = Event.None;